7 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
22 changed files with 699 additions and 124 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.12", "version": "0.1.15",
"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.9" version = "0.1.12"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"fontdb", "fontdb",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "margin-app" name = "margin-app"
version = "0.1.12" version = "0.1.15"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+92 -16
View File
@@ -16,50 +16,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)
} }
@@ -95,7 +161,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();
} }
+4 -1
View File
@@ -33,7 +33,7 @@ pub fn check(text: &str, custom: &HashSet<String>) -> Vec<MacIssue> {
checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount( checker.checkString_range_types_options_inSpellDocumentWithTag_orthography_wordCount(
&ns, &ns,
NSRange { location: 0, length: len }, NSRange { location: 0, length: len },
NSTextCheckingType::Spelling.bits(), (NSTextCheckingType::Spelling | NSTextCheckingType::Link).bits(),
None, None,
0, 0,
None, None,
@@ -45,6 +45,9 @@ pub fn check(text: &str, custom: &HashSet<String>) -> Vec<MacIssue> {
let chars: Vec<char> = text.chars().collect(); let chars: Vec<char> = text.chars().collect();
let mut issues = Vec::new(); let mut issues = Vec::new();
for result in results.iter() { for result in results.iter() {
if result.resultType() != NSTextCheckingType::Spelling {
continue;
}
let range = result.range(); let range = result.range();
let start = map[range.location.min(len)]; let start = map[range.location.min(len)];
let end = map[(range.location + range.length).min(len)]; let end = map[(range.location + range.length).min(len)];
+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.12", "version": "0.1.15",
"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 />}
</> </>
); );
} }
+22
View File
@@ -16,6 +16,7 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 }); const [coords, setCoords] = useState({ top: 0, left: 0 });
const btnRef = useRef<HTMLButtonElement>(null); const btnRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const toggle = () => { const toggle = () => {
if (!open && btnRef.current) { if (!open && btnRef.current) {
@@ -38,6 +39,25 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
}; };
}, [open]); }, [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) => { const pick = (group: "front" | "back", label: string) => {
setOpen(false); setOpen(false);
onAdd(group, label); onAdd(group, label);
@@ -56,9 +76,11 @@ export function AddPageMenu({ onAdd, onAddPart }: AddPageMenuProps) {
</button> </button>
{open && ( {open && (
<div <div
ref={popRef}
className="add-page-pop" className="add-page-pop"
style={{ top: coords.top, left: coords.left }} style={{ top: coords.top, left: coords.left }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onKeyDown={onMenuKeyDown}
> >
<div className="add-page-group"> <div className="add-page-group">
<div className="add-page-label">Body</div> <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"; import { Icon } from "./Icon";
interface ConfirmDialogProps { interface ConfirmDialogProps {
@@ -10,6 +10,21 @@ interface ConfirmDialogProps {
} }
export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfirm, onClose }: 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 ( 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()}>
@@ -26,7 +41,7 @@ export function ConfirmDialog({ title, message, confirmLabel = "Delete", onConfi
<button className="btn-ghost" onClick={onClose}> <button className="btn-ghost" onClick={onClose}>
Cancel Cancel
</button> </button>
<button className="btn-danger" onClick={onConfirm}> <button ref={confirmRef} className="btn-danger" onClick={onConfirm}>
{confirmLabel} {confirmLabel}
</button> </button>
</div> </div>
+107 -48
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";
@@ -44,6 +45,9 @@ export function EditorView() {
const isCompact = useCompact(); const isCompact = useCompact();
const [editor, setEditor] = useState<TiptapEditor | null>(null); 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 [sidebarOpen, setSidebarOpen] = useState(!isCompact);
const [dock, setDock] = useState(!isCompact); const [dock, setDock] = useState(!isCompact);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
@@ -83,6 +87,18 @@ export function EditorView() {
setFindOpen(true); 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(() => { const reproof = useCallback(() => {
if (!editor) return; if (!editor) return;
const state = useProofing.getState(); const state = useProofing.getState();
@@ -114,17 +130,58 @@ 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(); 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); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
}, [saveNow, openFind]); }, [widthOpen, exportOpen, moreOpen]);
useEffect(() => { useEffect(() => {
if (!notice) return; if (!notice) return;
@@ -177,8 +234,11 @@ export function EditorView() {
}; };
if (!book) return null; if (!book) return null;
const idx = book.chapters.findIndex((c) => c.id === activeChapterId); const activeIdx = book.chapters.findIndex((c) => c.id === activeChapterId);
const chapter = book.chapters[idx] ?? book.chapters[0]; 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 realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
const kind = chapter ? chapterKind(chapter) : "body"; const kind = chapter ? chapterKind(chapter) : "body";
const eyebrow = const eyebrow =
@@ -342,50 +402,49 @@ export function EditorView() {
/> />
<div className="body"> <div className="body">
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} /> <Sidebar
onNavigate={() => {
if (isCompact) setSidebarOpen(false);
focusEditorSoon();
}}
/>
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />} {sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
<main className="editor-pane"> <main className="editor-pane">
{coverActive ? ( {coverActive && <CoverView />}
<CoverView /> <article className="sheet" hidden={coverActive}>
) : ( <header className="chapter-opener">
<> <div className="chapter-num">{eyebrow}</div>
<article className="sheet"> <ChapterTitleInput
<header className="chapter-opener"> value={chapter.title}
<div className="chapter-num">{eyebrow}</div> placeholder={
<ChapterTitleInput chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
value={chapter.title} }
placeholder={ disabled={!!chapter.noTitle}
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title" onChange={(value) => setChapterTitle(chapter.id, value)}
} />
disabled={!!chapter.noTitle} {(chapter.noTitle || !chapter.title.trim()) && (
onChange={(value) => setChapterTitle(chapter.id, value)} <label className="no-title-check">
<input
type="checkbox"
checked={!!chapter.noTitle}
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)}
/> />
{(chapter.noTitle || !chapter.title.trim()) && ( No title
<label className="no-title-check"> </label>
<input )}
type="checkbox" </header>
checked={!!chapter.noTitle} <Editor
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)} bookId={book.id}
/> chapterId={chapter.id}
No title content={chapter.content}
</label> onChange={(content) => setChapterContent(chapter.id, content)}
)} onReady={setEditor}
</header> onContentError={() =>
<Editor setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
key={chapter.id} }
bookId={book.id} />
chapterId={chapter.id} </article>
content={chapter.content} {!coverActive && <FloatingToolbar editor={editor} />}
onChange={(content) => setChapterContent(chapter.id, content)}
onReady={setEditor}
onContentError={() =>
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
}
/>
</article>
<FloatingToolbar editor={editor} />
</>
)}
</main> </main>
{dock && !isCompact && <ResizeHandle pane="dock" />} {dock && !isCompact && <ResizeHandle pane="dock" />}
{dock && <Dock />} {dock && <Dock />}
+2 -2
View File
@@ -58,7 +58,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
return () => { return () => {
editor.off("transaction", update); editor.off("transaction", update);
}; };
}, [editor]); }, [editor, activeChapterId]);
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
@@ -67,7 +67,7 @@ export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps
} else { } else {
editor.commands.clearSearch(); editor.commands.clearSearch();
} }
}, [editor, open, query, caseSensitive, wholeWord]); }, [editor, open, query, caseSensitive, wholeWord, activeChapterId]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
+14 -1
View File
@@ -51,6 +51,11 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
onOpen(copy); onOpen(copy);
}; };
const openById = (id: string) =>
loadBook(id)
.then(onOpen)
.catch((e) => setNotice(`Could not open book: ${e}`));
const removeBook = async () => { const removeBook = async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
await deleteBook(pendingDelete.id); await deleteBook(pendingDelete.id);
@@ -108,7 +113,15 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
<div <div
key={b.id} key={b.id}
className="card card-book" 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> <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>}
+20 -1
View File
@@ -33,6 +33,25 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
setOpen((v) => !v); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
@@ -88,7 +107,7 @@ export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidd
</button> </button>
{open && {open &&
createPortal( 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 && ( {onToggleTitle && (
<button className="row-menu-item" onClick={toggleTitle}> <button className="row-menu-item" onClick={toggleTitle}>
{titleHidden ? ( {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; if (!book) return null;
const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch })); const set = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
@@ -121,7 +132,7 @@ export function Settings({ onClose, onSave }: { onClose: () => void; onSave: ()
</div> </div>
<div className="panel-body"> <div className="panel-body">
<Field label="Title"> <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>
<Field label="Subtitle"> <Field label="Subtitle">
<input value={draft.subtitle} onChange={(e) => set({ subtitle: e.target.value })} /> <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?.(); 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 ( return (
<aside className="sidebar"> <aside className="sidebar">
<button className="brand" onClick={closeBook} title="All books"> <button className="brand" onClick={closeBook} title="All books">
@@ -193,6 +220,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
key={row.chapter.id} key={row.chapter.id}
className="chapter" className="chapter"
data-idx={row.index} data-idx={row.index}
data-id={row.chapter.id}
data-kind={group.kind} data-kind={group.kind}
data-part={isPart} data-part={isPart}
data-indent={row.indent} data-indent={row.indent}
@@ -200,7 +228,11 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
data-dragging={dragIndex === row.index} data-dragging={dragIndex === row.index}
data-drop-before={dropTarget?.kind === group.kind && dropTarget.index === 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} 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)} onClick={() => onRowClick(row.chapter.id)}
onKeyDown={(e) => onRowKeyDown(e, row.index, row.chapter.id)}
onPointerDown={(e) => onRowPointerDown(e, row.index)} onPointerDown={(e) => onRowPointerDown(e, row.index)}
> >
<span className="grip" title="Drag to reorder"> <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 { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
import { EditorState } from "@tiptap/pm/state";
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions"; import { editorExtensions } from "./extensions";
import { loadPosition, savePosition, type ChapterPosition } from "./positions"; import { loadPosition, savePosition, type ChapterPosition } from "./positions";
import { useBook } from "../store/useBook";
interface EditorProps { interface EditorProps {
bookId: string; bookId: string;
@@ -13,13 +15,34 @@ interface EditorProps {
onContentError: (error: Error) => void; 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) { export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) {
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; onChangeRef.current = onChange;
const onContentErrorRef = useRef(onContentError); const onContentErrorRef = useRef(onContentError);
onContentErrorRef.current = onContentError; onContentErrorRef.current = onContentError;
const cache = useRef(new Map<string, Cached>());
const activeId = useRef(chapterId);
const latest = useRef<ChapterPosition | null>(null); const latest = useRef<ChapterPosition | null>(null);
const restoreToken = useRef(0);
const editor = useEditor({ const editor = useEditor({
extensions: editorExtensions, extensions: editorExtensions,
@@ -31,49 +54,84 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()), onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()),
}); });
useEffect(() => { const scrollerOf = (ed: TiptapEditor) => ed.view.dom.closest(".editor-pane") as HTMLElement | null;
onReady(editor);
return () => onReady(null); const restorePosition = (ed: TiptapEditor, id: string, focus: boolean) => {
}, [editor, onReady]); 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(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
let active = true; cache.current.set(chapterId, { state: editor.view.state, content, scroll: 0 });
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null; activeId.current = chapterId;
restorePosition(editor, chapterId, true);
const saved = loadPosition(bookId, chapterId); onReady(editor);
const size = editor.state.doc.content.size; return () => onReady(null);
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;
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]); }, [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(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null; const scroller = scrollerOf(editor);
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
const capture = () => { const capture = () => {
const { from, to } = editor.state.selection; 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 = () => { const persist = () => {
capture(); capture();
clearTimeout(timer); clearTimeout(timer);
timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400); timer = setTimeout(() => savePosition(bookId, activeId.current, latest.current!), 400);
}; };
editor.on("selectionUpdate", persist); editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true }); scroller?.addEventListener("scroll", persist, { passive: true });
@@ -81,9 +139,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady, onConten
clearTimeout(timer); clearTimeout(timer);
editor.off("selectionUpdate", persist); editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", 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" />; 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" }, 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 {
+10
View File
@@ -30,6 +30,7 @@ interface BookState {
openBook: (book: Book) => void; openBook: (book: Book) => void;
closeBook: () => void; closeBook: () => void;
setActiveChapter: (id: string) => void; setActiveChapter: (id: string) => void;
goToAdjacentChapter: (dir: 1 | -1) => void;
setChapterContent: (id: string, content: JSONContent) => void; setChapterContent: (id: string, content: JSONContent) => void;
setChapterTitle: (id: string, title: string) => void; setChapterTitle: (id: string, title: string) => void;
setChapterNoTitle: (id: string, noTitle: boolean) => void; setChapterNoTitle: (id: string, noTitle: boolean) => void;
@@ -73,6 +74,15 @@ export const useBook = create<BookState>((set, get) => ({
set({ book: null, activeChapterId: "", dirty: false }); set({ book: null, activeChapterId: "", dirty: false });
}, },
setActiveChapter: (id) => set({ activeChapterId: id }), 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) => setChapterContent: (id, content) =>
set((s) => set((s) =>
s.book 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; 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 { .app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1599,6 +1615,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 {
+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();
}