10 Commits
Author SHA1 Message Date
github-actions[bot] 4fa14306d1 chore(release): v0.1.15 2026-07-15 04:02:28 +00:00
pj acf78dfe29 feat(menu): reorganize native menu bar with discoverable actions
Move Check for Updates into the macOS app menu and add Settings (Cmd+,),
Save (Cmd+S), Find (Cmd+F), chapter navigation, and a Help > Report an
Issue link. Route new menu actions through menu-action events and trim
duplicate editor keybindings.
2026-07-15 09:31:57 +05:30
pj 866a971729 feat(updater): show in-app progress during app update
Replace native update dialogs with an in-app modal that shows a live
download progress bar and installing state, so updates no longer feel
abrupt. Drives the plugin's downloadAndInstall progress events through a
zustand store.
2026-07-15 09:31:57 +05:30
github-actions[bot] 7ea33b5bfb chore(release): v0.1.14 2026-07-15 04:00:48 +00:00
github-actions[bot] 0abeb3c5d4 chore(release): v0.1.13 2026-07-14 10:37:34 +00:00
pj a31f99cb76 fix keyboard fixed 2026-07-14 16:07:05 +05:30
pj 20365dd02d fix(proofing): stop flagging bare URLs as misspelled on macOS
NSSpellChecker's implicit URL handling is unreliable for URLs alone on a
line (a bookmark list), flagging the whole URL as one misspelling. Request
the link checking type alongside spelling so the data detector claims URL
ranges, then keep only spelling results. Real misspellings are unaffected.
2026-07-14 16:07:05 +05:30
github-actions[bot] af389c7097 chore(release): v0.1.12 2026-07-13 15:00:54 +00:00
pj f11ddd290f fix(proofing): use macOS NSSpellChecker for spelling on macOS
The bundled Hunspell dictionary (~49k SCOWL roots) lacked common words like
"cybersecurity", "scalable", and "assistantship", and checked words inside
URLs. On macOS, spelling now uses the system NSSpellChecker (same engine as
TextEdit/Safari): far better vocabulary, skips URLs, and respects words the
user has taught macOS. The custom/tech dictionaries and Harper grammar are
unchanged; non-macOS builds keep the Hunspell fallback.
2026-07-13 20:27:58 +05:30
github-actions[bot] 4975e76358 chore(release): v0.1.11 2026-07-13 14:51:25 +00:00
23 changed files with 801 additions and 93 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "margin-app",
"private": true,
"version": "0.1.10",
"version": "0.1.15",
"type": "module",
"scripts": {
"dev": "vite",
+26 -1
View File
@@ -4713,11 +4713,14 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "margin-app"
version = "0.1.7"
version = "0.1.12"
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"
+13 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "margin-app"
version = "0.1.10"
version = "0.1.15"
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,18 @@ 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"] }
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
+94 -16
View File
@@ -2,6 +2,8 @@ mod epub;
mod fonts;
mod gdrive;
mod library;
#[cfg(target_os = "macos")]
mod macspell;
mod pdf;
mod project;
mod proofing;
@@ -14,50 +16,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)
}
@@ -93,7 +161,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();
}
+73
View File
@@ -0,0 +1,73 @@
use std::collections::HashSet;
use objc2::rc::autoreleasepool;
use objc2_app_kit::NSSpellChecker;
use objc2_foundation::{NSRange, NSString, NSTextCheckingType};
pub struct MacIssue {
pub start: usize,
pub end: usize,
pub word: String,
pub suggestions: Vec<String>,
}
fn utf16_to_codepoint(text: &str, utf16_len: usize) -> Vec<usize> {
let mut map = Vec::with_capacity(utf16_len + 1);
let mut cp = 0;
for ch in text.chars() {
for _ in 0..ch.len_utf16() {
map.push(cp);
}
cp += 1;
}
map.push(cp);
map
}
pub fn check(text: &str, custom: &HashSet<String>) -> Vec<MacIssue> {
autoreleasepool(|_| {
let checker = NSSpellChecker::sharedSpellChecker();
let ns = NSString::from_str(text);
let len = ns.length();
let results = unsafe {
checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
&ns,
NSRange { location: 0, length: len },
(NSTextCheckingType::Spelling | NSTextCheckingType::Link).bits(),
None,
0,
None,
std::ptr::null_mut(),
)
};
let map = utf16_to_codepoint(text, len);
let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new();
for result in results.iter() {
if result.resultType() != NSTextCheckingType::Spelling {
continue;
}
let range = result.range();
let start = map[range.location.min(len)];
let end = map[(range.location + range.length).min(len)];
let word: String = chars[start..end].iter().collect();
if custom.contains(&word.to_lowercase()) {
continue;
}
let mut suggestions = Vec::new();
if let Some(guesses) =
checker.guessesForWordRange_inString_language_inSpellDocumentWithTag(range, &ns, None, 0)
{
for guess in guesses.iter() {
suggestions.push(guess.to_string());
if suggestions.len() >= 5 {
break;
}
}
}
issues.push(MacIssue { start, end, word, suggestions });
}
issues
})
}
+25
View File
@@ -27,6 +27,7 @@ struct Harper {
}
pub struct Engine {
#[cfg(not(target_os = "macos"))]
speller: Option<spellbook::Dictionary>,
harper: Option<Harper>,
custom: HashSet<String>,
@@ -87,6 +88,7 @@ fn load_custom(app: &tauri::AppHandle) -> HashSet<String> {
set
}
#[cfg(not(target_os = "macos"))]
fn build_speller(app: &tauri::AppHandle) -> Result<spellbook::Dictionary, String> {
let aff = dict_file(app, "index.aff")?;
let dic = dict_file(app, "index.dic")?;
@@ -100,18 +102,22 @@ fn build_harper() -> Harper {
Harper { linter, dict }
}
#[cfg(not(target_os = "macos"))]
fn is_word_char(c: char) -> bool {
c.is_alphabetic()
}
#[cfg(not(target_os = "macos"))]
fn has_irregular_case(word: &str) -> bool {
word.chars().skip(1).any(|c| c.is_uppercase())
}
#[cfg(not(target_os = "macos"))]
fn is_apostrophe(c: char) -> bool {
c == '\'' || c == '\u{2019}'
}
#[cfg(not(target_os = "macos"))]
fn collect_spelling(
speller: &spellbook::Dictionary,
custom: &HashSet<String>,
@@ -143,6 +149,10 @@ fn collect_spelling(
}
let mut suggestions = Vec::new();
speller.suggest(&word, &mut suggestions);
let lower = word.to_lowercase();
if suggestions.iter().any(|s| s.to_lowercase() == lower) {
continue;
}
suggestions.truncate(5);
issues.push(Issue {
start,
@@ -197,6 +207,7 @@ pub async fn proof_text(
let mut custom = load_custom(&app);
load_tech(&app, &mut custom);
*guard = Some(Engine {
#[cfg(not(target_os = "macos"))]
speller: None,
harper: None,
custom,
@@ -207,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());
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Margin",
"version": "0.1.10",
"version": "0.1.15",
"identifier": "studio.margin.app",
"build": {
"beforeDevCommand": "pnpm dev",
+5
View File
@@ -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 />}
</>
);
}
+22
View File
@@ -16,6 +16,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 +39,25 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
};
}, [open]);
useEffect(() => {
if (!open) return;
popRef.current?.querySelector<HTMLElement>(".add-page-item")?.focus();
}, [open]);
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
btnRef.current?.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const items = Array.from(popRef.current?.querySelectorAll<HTMLElement>(".add-page-item") ?? []);
const idx = items.indexOf(document.activeElement as HTMLElement);
const nextIdx = e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
items[nextIdx]?.focus();
}
};
const pick = (group: "front" | "back", label: string) => {
setOpen(false);
onAdd(group, label);
@@ -56,9 +76,11 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
</button>
{open && (
<div
ref={popRef}
className="add-page-pop"
style={{ top: coords.top, left: coords.left }}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={onMenuKeyDown}
>
<div className="add-page-group">
<div className="add-page-label">Body</div>
+17 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useEffect, useRef, type ReactNode } from "react";
import { Icon } from "./Icon";
interface ConfirmDialogProps {
@@ -10,6 +10,21 @@ interface ConfirmDialogProps {
}
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: ConfirmDialogProps) {
const confirmRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
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
}, []);
return (
<div className="overlay" onClick={onClose}>
<div className="panel panel-confirm" onClick={(e) => e.stopPropagation()}>
@@ -26,7 +41,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>
+76 -17
View File
@@ -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";
@@ -44,6 +45,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);
@@ -83,6 +87,18 @@ export function EditorView() {
setFindOpen(true);
}, []);
const focusEditorSoon = useCallback(() => {
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 +130,58 @@ 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);
}
};
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 (!widthOpen && !exportOpen && !moreOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setWidthOpen(false);
setExportOpen(false);
setMoreOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [saveNow, openFind]);
}, [widthOpen, exportOpen, moreOpen]);
useEffect(() => {
if (!notice) return;
@@ -177,8 +234,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 +402,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
@@ -372,7 +434,6 @@ export function EditorView() {
)}
</header>
<Editor
key={chapter.id}
bookId={book.id}
chapterId={chapter.id}
content={chapter.content}
@@ -383,9 +444,7 @@ export function EditorView() {
}
/>
</article>
<FloatingToolbar editor={editor} />
</>
)}
{!coverActive && <FloatingToolbar editor={editor} />}
</main>
{dock && !isCompact && <ResizeHandle pane="dock" />}
{dock && <Dock />}
+2 -2
View File
@@ -58,7 +58,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
return () => {
editor.off("transaction", update);
};
}, [editor]);
}, [editor, activeChapterId]);
useEffect(() => {
if (!editor) return;
@@ -67,7 +67,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) {
+14 -1
View File
@@ -51,6 +51,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,7 +113,15 @@ 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>}
+20 -1
View File
@@ -33,6 +33,25 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
setOpen((v) => !v);
};
useEffect(() => {
if (!open) return;
popRef.current?.querySelector<HTMLElement>(".row-menu-item")?.focus();
}, [open]);
const onMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
btnRef.current?.focus();
} else 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 +107,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 ? (
+12 -1
View File
@@ -94,6 +94,17 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
});
}, []);
useEffect(() => {
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;
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
@@ -121,7 +132,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 })} />
+32
View File
@@ -155,6 +155,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 +220,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 +228,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">
+104
View File
@@ -0,0 +1,104 @@
import { useUpdater } from "../store/useUpdater";
import { installUpdate, dismissUpdate } from "../updater";
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);
if (phase === "idle") return null;
const busy = phase === "downloading" || phase === "installing";
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>
);
}
+89 -31
View File
@@ -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,13 +15,34 @@ interface EditorProps {
onContentError: (error: Error) => void;
}
interface Cached {
state: EditorState;
content: JSONContent;
scroll: number;
}
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,
@@ -31,49 +54,84 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
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 +139,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 -1
View File
@@ -20,7 +20,7 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
a5: { w: "148mm", h: "210mm" },
};
const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g;
const INLINE_SPECIAL = /[\\#$*_`<>@~()[\]]/g;
const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g;
function esc(text: string): string {
+10
View File
@@ -30,6 +30,7 @@ interface BookState {
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;
@@ -73,6 +74,15 @@ export const useBook = create<BookState>((set, get) => ({
set({ book: null, activeChapterId: "", dirty: false });
},
setActiveChapter: (id) => set({ activeChapterId: id }),
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 });
},
setChapterContent: (id, content) =>
set((s) =>
s.book
+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),
}));
+82
View File
@@ -40,6 +40,22 @@ button {
padding: 0;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.chapter:focus-visible,
.card:focus-visible {
outline-offset: -2px;
}
.prose:focus,
.prose:focus-visible,
.editor-host:focus-visible {
outline: none;
}
.app {
display: flex;
flex-direction: column;
@@ -1599,6 +1615,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 {
+43 -16
View File
@@ -1,31 +1,58 @@
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { ask, message } from "@tauri-apps/plugin-dialog";
import { useUpdater } from "./store/useUpdater";
let running = false;
let checking = false;
export async function checkForUpdates(silent: boolean) {
if (running) return;
running = true;
if (checking) return;
checking = true;
const store = useUpdater.getState();
if (!silent) store.set({ phase: "checking", error: "" });
try {
const update = await check();
if (!update) {
if (!silent) await message("margin is up to date.", { title: "Check for Updates" });
store.set(silent ? { phase: "idle" } : { phase: "uptodate" });
return;
}
const notes = update.body ? `\n\n${update.body}` : "";
const install = await ask(`margin ${update.version} is available.${notes}\n\nInstall now and restart?`, {
title: "Update Available",
kind: "info",
store.set({
phase: "available",
update,
version: update.version,
notes: update.body ?? "",
downloaded: 0,
total: 0,
error: "",
});
if (!install) return;
await update.downloadAndInstall();
} 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();
}