This commit is contained in:
pj committed 2026-06-22 09:56:20 -04:00
1 parent 78fff9b6f4
commit 7cc36e3e40
37 files changed
+55361 -103

No files matched your search

+3
View File
@@ -6,6 +6,7 @@ import { useBook } from "./store/useBook";
import { isDesktop } from "./ipc";
import { newBook } from "./library";
import { runExport } from "./export/run";
import { checkForUpdates } from "./updater";
function App() {
const book = useBook((s) => s.book);
@@ -13,11 +14,13 @@ function App() {
useEffect(() => {
if (!isDesktop) return;
checkForUpdates(true);
const unlisten = listen<string>("menu-action", (event) => {
const state = useBook.getState();
if (event.payload === "new-book") state.openBook(newBook());
else if (event.payload === "export-pdf") runExport("pdf");
else if (event.payload === "export-epub") runExport("epub");
else if (event.payload === "check-updates") checkForUpdates(false);
});
return () => {
unlisten.then((stop) => stop());
+1 -1
View File
@@ -133,7 +133,7 @@ function HtmlDock() {
<div className="page">
<div className="p-opener">
<div className="p-num">{eyebrow}</div>
<div className="p-title">{chapter.title || "Untitled"}</div>
{!chapter.noTitle && <div className="p-title">{chapter.title || "Untitled"}</div>}
</div>
<div className="page-body" dangerouslySetInnerHTML={{ __html: html }} />
<div className="folio">{idx * 8 + 7}</div>
+127 -4
View File
@@ -6,15 +6,20 @@ import { ResizeHandle } from "./ResizeHandle";
import { Icon } from "./Icon";
import { Settings } from "./Settings";
import { CoverView } from "./CoverView";
import { FindBar } from "./FindBar";
import { ProofPopover } from "./ProofPopover";
import { Editor } from "../editor/Editor";
import { FloatingToolbar } from "../editor/FloatingToolbar";
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
import { COVER_ID, useBook } from "../store/useBook";
import { useProofing } from "../store/useProofing";
import { useTheme } from "../store/useTheme";
import { useWidth } from "../store/useWidth";
import { WIDTH_OPTIONS } from "../width";
import { bodyNumber, chapterKind } from "../model/book";
import { saveBook } from "../library";
import { isDesktop } from "../ipc";
import { issueSignature, rememberWord, runProof } from "../proofing";
import { runExport } from "../export/run";
export function EditorView() {
@@ -26,6 +31,7 @@ export function EditorView() {
const setNotice = useBook((s) => s.setNotice);
const setChapterContent = useBook((s) => s.setChapterContent);
const setChapterTitle = useBook((s) => s.setChapterTitle);
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
const markSaved = useBook((s) => s.markSaved);
const theme = useTheme((s) => s.theme);
const toggleTheme = useTheme((s) => s.toggle);
@@ -37,6 +43,35 @@ export function EditorView() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [widthOpen, setWidthOpen] = useState(false);
const [findOpen, setFindOpen] = useState(false);
const [findExpanded, setFindExpanded] = useState(false);
const [proofPopover, setProofPopover] = useState<{ issue: ProofIssue; coords: ProofCoords } | null>(null);
const spelling = useProofing((s) => s.spelling);
const grammar = useProofing((s) => s.grammar);
const ignored = useProofing((s) => s.ignored);
const toggleSpelling = useProofing((s) => s.toggleSpelling);
const toggleGrammar = useProofing((s) => s.toggleGrammar);
const coverActive = activeChapterId === COVER_ID;
const proofingAvailable = isDesktop && book?.metadata.language === "en";
const openFind = useCallback((expanded: boolean) => {
setFindExpanded(expanded);
setFindOpen(true);
}, []);
const reproof = useCallback(() => {
if (!editor) return;
const state = useProofing.getState();
if (!proofingAvailable || (!state.spelling && !state.grammar)) {
editor.commands.clearProofIssues();
return;
}
runProof(editor.state.doc, { spelling: state.spelling, grammar: state.grammar }, state.ignored)
.then((issues) => editor.commands.setProofIssues(issues))
.catch(() => {});
}, [editor, proofingAvailable]);
const saveNow = useCallback(() => {
const current = useBook.getState().book;
@@ -54,11 +89,14 @@ export function EditorView() {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
saveNow();
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
e.preventDefault();
openFind(e.altKey);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [saveNow]);
}, [saveNow, openFind]);
useEffect(() => {
if (!notice) return;
@@ -66,13 +104,45 @@ export function EditorView() {
return () => clearTimeout(timer);
}, [notice]);
useEffect(() => {
if (!editor) return;
const storage = (editor.storage as unknown as Record<string, ProofingStorage>).proofing;
storage.onClickIssue = (issue, coords) => setProofPopover({ issue, coords });
const closePopover = () => setProofPopover(null);
editor.on("update", closePopover);
return () => {
storage.onClickIssue = null;
editor.off("update", closePopover);
};
}, [editor]);
useEffect(() => {
if (!editor) return;
if (!proofingAvailable || coverActive || (!spelling && !grammar)) {
editor.commands.clearProofIssues();
return;
}
let timer: ReturnType<typeof setTimeout>;
const schedule = (delay: number) => {
clearTimeout(timer);
timer = setTimeout(reproof, delay);
};
editor.commands.clearProofIssues();
schedule(150);
const onUpdate = () => schedule(500);
editor.on("update", onUpdate);
return () => {
clearTimeout(timer);
editor.off("update", onUpdate);
};
}, [editor, spelling, grammar, ignored, activeChapterId, coverActive, proofingAvailable, reproof]);
const handleExport = (format: "pdf" | "epub") => {
setExportOpen(false);
runExport(format);
};
if (!book) return null;
const coverActive = activeChapterId === COVER_ID;
const idx = book.chapters.findIndex((c) => c.id === activeChapterId);
const chapter = book.chapters[idx] ?? book.chapters[0];
const realIdx = book.chapters.findIndex((c) => c.id === chapter?.id);
@@ -88,6 +158,19 @@ export function EditorView() {
{dirty && <span className="dirty-dot" />}
</button>
<div className="actions">
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
</button>
{proofingAvailable && !coverActive && (
<>
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
<Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
</button>
<button className="icon-btn" data-on={grammar} onClick={toggleGrammar} title="Check grammar">
<Icon d="M4 7h16M4 12h12M4 17h7M13.5 18.5c1-1.2 2-1.2 3 0s2 1.2 3 0" />
</button>
</>
)}
<div className="menu-wrap">
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
@@ -142,6 +225,13 @@ export function EditorView() {
</div>
</header>
<FindBar
editor={coverActive ? null : editor}
open={findOpen && !coverActive}
initialExpanded={findExpanded}
onClose={() => setFindOpen(false)}
/>
<div className="body">
<Sidebar />
<ResizeHandle pane="sidebar" />
@@ -155,11 +245,22 @@ export function EditorView() {
<div className="chapter-num">{eyebrow}</div>
<input
className="chapter-title-input"
value={chapter.title}
placeholder={kind === "body" ? "Chapter title" : "Page title"}
value={chapter.noTitle ? "" : chapter.title}
placeholder={chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : "Page title"}
spellCheck={false}
disabled={chapter.noTitle}
onChange={(e) => setChapterTitle(chapter.id, e.target.value)}
/>
{(chapter.noTitle || !chapter.title.trim()) && (
<label className="no-title-check">
<input
type="checkbox"
checked={!!chapter.noTitle}
onChange={(e) => setChapterNoTitle(chapter.id, e.target.checked)}
/>
No title
</label>
)}
</header>
<Editor
chapterId={chapter.id}
@@ -176,6 +277,28 @@ export function EditorView() {
{dock && <Dock />}
</div>
{proofPopover && editor && (
<ProofPopover
issue={proofPopover.issue}
coords={proofPopover.coords}
onReplace={(suggestion) => {
const { from, to } = proofPopover.issue;
if (suggestion === "") editor.chain().focus().deleteRange({ from, to }).run();
else editor.chain().focus().insertContentAt({ from, to }, suggestion).run();
setProofPopover(null);
}}
onIgnore={() => {
useProofing.getState().ignore(issueSignature(proofPopover.issue));
setProofPopover(null);
}}
onRemember={() => {
rememberWord(proofPopover.issue.word).then(reproof).catch(() => {});
setProofPopover(null);
}}
onClose={() => setProofPopover(null)}
/>
)}
{settingsOpen && <Settings onClose={() => setSettingsOpen(false)} onSave={saveNow} />}
{exporting && (
<div className="export-overlay">
+283
View File
@@ -0,0 +1,283 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { Editor as TiptapEditor } from "@tiptap/react";
import { buildRegex, getSearchState } from "../editor/search";
import { countMatches, replaceInContent } from "../search-book";
import { useBook } from "../store/useBook";
import { Icon } from "./Icon";
interface FindBarProps {
editor: TiptapEditor | null;
open: boolean;
initialExpanded: boolean;
onClose: () => void;
}
type Scope = "chapter" | "book";
type PendingNav = "first" | "last" | null;
export function FindBar({ editor, open, initialExpanded, onClose }: FindBarProps) {
const book = useBook((s) => s.book);
const activeChapterId = useBook((s) => s.activeChapterId);
const setActiveChapter = useBook((s) => s.setActiveChapter);
const setChapterContent = useBook((s) => s.setChapterContent);
const setNotice = useBook((s) => s.setNotice);
const [query, setQuery] = useState("");
const [replacement, setReplacement] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [wholeWord, setWholeWord] = useState(false);
const [expanded, setExpanded] = useState(initialExpanded);
const [scope, setScope] = useState<Scope>("chapter");
const [active, setActive] = useState({ count: 0, current0: 0 });
const [pendingNav, setPendingNav] = useState<PendingNav>(null);
const findInputRef = useRef<HTMLInputElement>(null);
const chapters = useMemo(() => book?.chapters ?? [], [book]);
const activeIdx = chapters.findIndex((c) => c.id === activeChapterId);
const options = { caseSensitive, wholeWord };
const jsonCounts = useMemo(() => {
const regex = query ? buildRegex(query, options) : null;
if (!regex) return chapters.map(() => 0);
return chapters.map((c) => countMatches(c.content, regex));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chapters, query, caseSensitive, wholeWord]);
useEffect(() => {
if (open) setExpanded(initialExpanded);
}, [open, initialExpanded]);
useEffect(() => {
if (!editor) return;
const update = () => {
const s = getSearchState(editor.state);
setActive({ count: s?.matches.length ?? 0, current0: s?.current ?? 0 });
};
editor.on("transaction", update);
update();
return () => {
editor.off("transaction", update);
};
}, [editor]);
useEffect(() => {
if (!editor) return;
if (open) {
editor.commands.setSearch(query, { caseSensitive, wholeWord });
} else {
editor.commands.clearSearch();
}
}, [editor, open, query, caseSensitive, wholeWord]);
useEffect(() => {
if (open) {
const input = findInputRef.current;
input?.focus();
input?.select();
}
}, [open]);
useEffect(() => {
if (!editor || !pendingNav) return;
let done = false;
const go = () => {
if (done) return;
done = true;
const s = getSearchState(editor.state);
if (s && s.matches.length) editor.commands.goToMatch(pendingNav === "first" ? 0 : s.matches.length - 1);
setPendingNav(null);
};
editor.on("transaction", go);
const raf = requestAnimationFrame(go);
return () => {
editor.off("transaction", go);
cancelAnimationFrame(raf);
};
}, [editor, pendingNav, activeChapterId]);
if (!open || !editor) return null;
const before = jsonCounts.slice(0, Math.max(0, activeIdx)).reduce((a, b) => a + b, 0);
const after = jsonCounts.slice(activeIdx + 1).reduce((a, b) => a + b, 0);
const total = before + active.count + after;
const globalCurrent = before + active.current0 + 1;
const otherHasMatches = chapters.some((c, i) => c.id !== activeChapterId && jsonCounts[i] > 0);
const countLabel = !query
? ""
: total === 0
? "No results"
: active.count === 0
? `${total} found`
: `${globalCurrent} of ${total}`;
const nextChapterWith = (dir: 1 | -1): string | null => {
const n = chapters.length;
if (!n || activeIdx < 0) return null;
for (let step = 1; step <= n; step++) {
const i = (((activeIdx + dir * step) % n) + n) % n;
if (chapters[i].id !== activeChapterId && jsonCounts[i] > 0) return chapters[i].id;
}
return null;
};
const next = () => {
const s = getSearchState(editor.state);
const count = s?.matches.length ?? 0;
const cur = s?.current ?? 0;
if (count > 0 && cur < count - 1) {
editor.commands.findNext();
return;
}
if (otherHasMatches) {
const id = nextChapterWith(1);
if (id) {
setPendingNav("first");
setActiveChapter(id);
return;
}
}
editor.commands.findNext();
};
const prev = () => {
const s = getSearchState(editor.state);
const count = s?.matches.length ?? 0;
const cur = s?.current ?? 0;
if (count > 0 && cur > 0) {
editor.commands.findPrev();
return;
}
if (otherHasMatches) {
const id = nextChapterWith(-1);
if (id) {
setPendingNav("last");
setActiveChapter(id);
return;
}
}
editor.commands.findPrev();
};
const onFindKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
e.shiftKey ? prev() : next();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
const replaceOne = () => {
editor.commands.replaceCurrent(replacement);
editor.commands.focus();
};
const replaceAll = () => {
if (scope === "chapter") {
editor.commands.replaceAllInChapter(replacement);
editor.commands.focus();
return;
}
const regex = buildRegex(query, options);
if (!regex) return;
let totalReplaced = 0;
let touched = 0;
const s = getSearchState(editor.state);
const activeCount = s?.matches.length ?? 0;
if (activeCount > 0) {
editor.commands.replaceAllInChapter(replacement);
totalReplaced += activeCount;
touched++;
}
for (const c of chapters) {
if (c.id === activeChapterId) continue;
const result = replaceInContent(c.content, regex, replacement);
if (result.count > 0) {
setChapterContent(c.id, result.content);
totalReplaced += result.count;
touched++;
}
}
setNotice(`Replaced ${totalReplaced} across ${touched} chapter${touched === 1 ? "" : "s"}`);
editor.commands.focus();
};
return (
<div className="find-bar glass">
<button
className="find-expand"
data-on={expanded}
title={expanded ? "Hide replace" : "Show replace"}
onClick={() => setExpanded((v) => !v)}
>
<Icon d={expanded ? "M6 9l6 6 6-6" : "M9 6l6 6-6 6"} size={14} />
</button>
<div className="find-stack">
<div className="find-row">
<input
ref={findInputRef}
className="find-input"
value={query}
placeholder="Find"
spellCheck={false}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onFindKey}
/>
<span className="find-count">{countLabel}</span>
<button className="find-btn" title="Previous (⇧⏎)" disabled={!total} onClick={prev}>
<Icon d="M6 15l6-6 6 6" size={14} />
</button>
<button className="find-btn" title="Next (⏎)" disabled={!total} onClick={next}>
<Icon d="M6 9l6 6 6-6" size={14} />
</button>
<button className="find-toggle" data-on={caseSensitive} title="Match case" onClick={() => setCaseSensitive((v) => !v)}>
Aa
</button>
<button className="find-toggle" data-on={wholeWord} title="Whole word" onClick={() => setWholeWord((v) => !v)}>
<span className="find-ww">ab</span>
</button>
<button className="find-btn" title="Close (Esc)" onClick={onClose}>
<Icon d="M18 6L6 18M6 6l12 12" size={14} />
</button>
</div>
{expanded && (
<div className="find-row">
<input
className="find-input"
value={replacement}
placeholder="Replace"
spellCheck={false}
onChange={(e) => setReplacement(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
replaceOne();
} else if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}}
/>
<button className="find-action" disabled={!active.count} onClick={replaceOne} title="Replace current match">
Replace
</button>
<button className="find-action" disabled={!total} onClick={replaceAll} title="Replace all matches">
Replace All
</button>
<button
className="find-scope"
title="Replace All scope"
onClick={() => setScope((s) => (s === "chapter" ? "book" : "chapter"))}
>
{scope === "chapter" ? "This chapter" : "Whole book"}
</button>
</div>
)}
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { severityFor, type ProofCoords, type ProofIssue } from "../editor/proofing";
function humanize(category: string): string {
return category.replace(/([a-z])([A-Z])/g, "$1 $2");
}
interface ProofPopoverProps {
issue: ProofIssue;
coords: ProofCoords;
onReplace: (suggestion: string) => void;
onIgnore: () => void;
onRemember: () => void;
onClose: () => void;
}
const POP_WIDTH = 264;
export function ProofPopover({ issue, coords, onReplace, onIgnore, onRemember, onClose }: ProofPopoverProps) {
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (popRef.current?.contains(e.target as Node)) return;
onClose();
};
const close = () => onClose();
document.addEventListener("mousedown", onDown, true);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", onDown, true);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [onClose]);
const left = Math.min(Math.max(8, coords.left - POP_WIDTH / 2), window.innerWidth - POP_WIDTH - 8);
const top = coords.bottom + 6;
return createPortal(
<div
ref={popRef}
className="proof-pop"
style={{ left, top, width: POP_WIDTH }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className={`proof-pop-kind sev-${severityFor(issue.category)}`}>{humanize(issue.category)}</div>
<div className="proof-pop-msg">{issue.message}</div>
{issue.suggestions.length > 0 && (
<div className="proof-pop-suggestions">
{issue.suggestions.map((suggestion, i) => (
<button key={i} className="proof-suggestion" onClick={() => onReplace(suggestion)}>
{suggestion === "" ? "Remove" : suggestion}
</button>
))}
</div>
)}
<div className="proof-pop-actions">
<button className="proof-action" onClick={onIgnore}>
Ignore
</button>
{issue.kind === "spelling" && (
<button className="proof-action" onClick={onRemember}>
Remember
</button>
)}
</div>
</div>,
document.body
);
}
+14 -1
View File
@@ -4,11 +4,12 @@ import { Icon } from "./Icon";
interface RowMenuProps {
label: string;
onDuplicate?: () => void;
onDelete: () => void;
className?: string;
}
export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
export function RowMenu({ label, onDuplicate, onDelete, className = "" }: RowMenuProps) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({ top: 0, right: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
@@ -40,6 +41,12 @@ export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
};
}, [open]);
const duplicate = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
onDuplicate?.();
};
const remove = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(false);
@@ -61,6 +68,12 @@ export function RowMenu({ label, onDelete, className = "" }: RowMenuProps) {
{open &&
createPortal(
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
{onDuplicate && (
<button className="row-menu-item" onClick={duplicate}>
<Icon d="M9 9h11v11h-11z M6 15V5h9" size={14} />
Duplicate
</button>
)}
<button className="row-menu-item danger" onClick={remove}>
<Icon d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13M10 11v6M14 11v6" size={14} />
Delete
+2
View File
@@ -43,6 +43,7 @@ export function Sidebar() {
const setActiveChapter = useBook((s) => s.setActiveChapter);
const addChapter = useBook((s) => s.addChapter);
const addPage = useBook((s) => s.addPage);
const duplicateChapter = useBook((s) => s.duplicateChapter);
const deleteChapter = useBook((s) => s.deleteChapter);
const moveChapter = useBook((s) => s.moveChapter);
const closeBook = useBook((s) => s.closeBook);
@@ -195,6 +196,7 @@ export function Sidebar() {
</span>
<RowMenu
label="Page options"
onDuplicate={() => duplicateChapter(row.chapter.id)}
onDelete={() => setPendingDelete({ id: row.chapter.id, title: row.chapter.title || "Untitled" })}
/>
</li>
+11 -1
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
import type { Editor } from "@tiptap/react";
import { Icon } from "../components/Icon";
@@ -23,11 +23,21 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
const linkInputRef = useRef<HTMLInputElement>(null);
const [linkOpen, setLinkOpen] = useState(false);
const [linkValue, setLinkValue] = useState("");
const [, forceUpdate] = useReducer((x) => x + 1, 0);
useEffect(() => {
if (linkOpen) linkInputRef.current?.focus();
}, [linkOpen]);
useEffect(() => {
if (!editor) return;
const update = () => forceUpdate();
editor.on("transaction", update);
return () => {
editor.off("transaction", update);
};
}, [editor]);
if (!editor) return null;
const insertImage = async (file: File) => {
+4
View File
@@ -3,6 +3,8 @@ import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Figure } from "./figure";
import { ParagraphIndent } from "./indent";
import { SearchHighlight } from "./search";
import { Proofing } from "./proofing";
export const editorExtensions: Extensions = [
StarterKit.configure({
@@ -15,4 +17,6 @@ export const editorExtensions: Extensions = [
}),
Figure,
ParagraphIndent,
SearchHighlight,
Proofing,
];
+133
View File
@@ -0,0 +1,133 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { Node as PMNode } from "@tiptap/pm/model";
export interface ProofIssue {
from: number;
to: number;
kind: "spelling" | "grammar";
category: string;
message: string;
suggestions: string[];
word: string;
}
export type ProofSeverity = "error" | "warn" | "suggest";
const WARN_CATEGORIES = new Set(["Redundancy", "Repetition", "Formatting", "Regionalism"]);
const SUGGEST_CATEGORIES = new Set(["Style", "Readability", "Enhancement", "Miscellaneous"]);
export function severityFor(category: string): ProofSeverity {
if (WARN_CATEGORIES.has(category)) return "warn";
if (SUGGEST_CATEGORIES.has(category)) return "suggest";
return "error";
}
export interface ProofCoords {
left: number;
top: number;
bottom: number;
}
interface ProofingState {
issues: ProofIssue[];
decorations: DecorationSet;
}
export const proofingKey = new PluginKey<ProofingState>("proofing");
function buildDecorations(doc: PMNode, issues: ProofIssue[]): DecorationSet {
const size = doc.content.size;
const decos = issues
.filter((i) => i.from < i.to && i.to <= size)
.map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` }));
return DecorationSet.create(doc, decos);
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
proofing: {
setProofIssues: (issues: ProofIssue[]) => ReturnType;
clearProofIssues: () => ReturnType;
};
}
}
export interface ProofingStorage {
onClickIssue: ((issue: ProofIssue, coords: ProofCoords) => void) | null;
}
export const Proofing = Extension.create<unknown, ProofingStorage>({
name: "proofing",
addStorage() {
return { onClickIssue: null };
},
addProseMirrorPlugins() {
const storage = this.storage;
return [
new Plugin<ProofingState>({
key: proofingKey,
state: {
init: () => ({ issues: [], decorations: DecorationSet.empty }),
apply(tr, value, _old, newState) {
const meta = tr.getMeta(proofingKey) as { issues: ProofIssue[] } | { clear: true } | undefined;
if (meta && "issues" in meta) {
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues) };
}
if (meta && "clear" in meta) {
return { issues: [], decorations: DecorationSet.empty };
}
if (tr.docChanged && value.issues.length) {
const issues = value.issues
.map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) }))
.filter((i) => i.to > i.from);
return { issues, decorations: value.decorations.map(tr.mapping, tr.doc) };
}
return value;
},
},
props: {
decorations(state) {
return proofingKey.getState(state)?.decorations ?? DecorationSet.empty;
},
handleClick(view, pos) {
const s = proofingKey.getState(view.state);
if (!s || !s.issues.length) return false;
const hits = s.issues.filter((i) => pos >= i.from && pos <= i.to);
if (!hits.length) return false;
const issue = hits.find((i) => i.kind === "spelling") ?? hits[0];
if (!storage.onClickIssue) return false;
const start = view.coordsAtPos(issue.from);
const end = view.coordsAtPos(issue.to);
storage.onClickIssue(issue, {
left: (start.left + end.left) / 2,
top: start.top,
bottom: end.bottom,
});
return false;
},
},
}),
];
},
addCommands() {
return {
setProofIssues:
(issues) =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(proofingKey, { issues }));
return true;
},
clearProofIssues:
() =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(proofingKey, { clear: true }));
return true;
},
};
},
});
+233
View File
@@ -0,0 +1,233 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view";
import type { Node as PMNode } from "@tiptap/pm/model";
export interface SearchOptions {
caseSensitive: boolean;
wholeWord: boolean;
}
export interface SearchMatch {
from: number;
to: number;
}
interface SearchState {
query: string;
options: SearchOptions;
matches: SearchMatch[];
current: number;
decorations: DecorationSet;
}
export const searchKey = new PluginKey<SearchState>("search");
const EMPTY: SearchState = {
query: "",
options: { caseSensitive: false, wholeWord: false },
matches: [],
current: 0,
decorations: DecorationSet.empty,
};
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function buildRegex(query: string, options: SearchOptions): RegExp | null {
if (!query) return null;
let pattern = escapeRegExp(query);
if (options.wholeWord) pattern = `\\b${pattern}\\b`;
try {
return new RegExp(pattern, options.caseSensitive ? "g" : "gi");
} catch {
return null;
}
}
export function findMatches(doc: PMNode, regex: RegExp): SearchMatch[] {
const matches: SearchMatch[] = [];
let runText = "";
let runStart = 0;
const flush = () => {
if (!runText) return;
regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(runText)) !== null) {
const from = runStart + m.index;
matches.push({ from, to: from + m[0].length });
if (m.index === regex.lastIndex) regex.lastIndex++;
}
runText = "";
};
doc.descendants((node, pos) => {
if (node.isText) {
if (!runText) runStart = pos;
runText += node.text ?? "";
} else {
flush();
}
return true;
});
flush();
return matches;
}
function buildDecorations(doc: PMNode, matches: SearchMatch[], current: number): DecorationSet {
if (!matches.length) return DecorationSet.empty;
const decos = matches.map((m, i) =>
Decoration.inline(m.from, m.to, {
class: i === current ? "search-match search-match-current" : "search-match",
})
);
return DecorationSet.create(doc, decos);
}
function recompute(doc: PMNode, query: string, options: SearchOptions, desiredCurrent: number): SearchState {
const regex = buildRegex(query, options);
if (!regex) return { ...EMPTY, query, options };
const matches = findMatches(doc, regex);
const current = matches.length ? Math.max(0, Math.min(desiredCurrent, matches.length - 1)) : 0;
return { query, options, matches, current, decorations: buildDecorations(doc, matches, current) };
}
function scrollMatchIntoView(view: EditorView, match: SearchMatch) {
const { node } = view.domAtPos(match.from);
const el = node.nodeType === Node.TEXT_NODE ? (node as Text).parentElement : (node as HTMLElement);
el?.scrollIntoView({ block: "center", behavior: "smooth" });
}
export function getSearchState(state: { plugins: unknown } | unknown): SearchState | undefined {
return searchKey.getState(state as never);
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
search: {
setSearch: (query: string, options: SearchOptions) => ReturnType;
clearSearch: () => ReturnType;
findNext: () => ReturnType;
findPrev: () => ReturnType;
goToMatch: (index: number) => ReturnType;
replaceCurrent: (replacement: string) => ReturnType;
replaceAllInChapter: (replacement: string) => ReturnType;
};
}
}
export const SearchHighlight = Extension.create({
name: "searchHighlight",
addProseMirrorPlugins() {
return [
new Plugin<SearchState>({
key: searchKey,
state: {
init: () => EMPTY,
apply(tr, value, _old, newState) {
const meta = tr.getMeta(searchKey) as
| { type: "search"; query: string; options: SearchOptions }
| { type: "nav"; current: number }
| { type: "clear" }
| undefined;
if (meta?.type === "search") {
return recompute(newState.doc, meta.query, meta.options, 0);
}
if (meta?.type === "nav" && value.matches.length) {
const current = ((meta.current % value.matches.length) + value.matches.length) % value.matches.length;
return { ...value, current, decorations: buildDecorations(newState.doc, value.matches, current) };
}
if (meta?.type === "clear") {
return { ...EMPTY };
}
if (tr.docChanged && value.query) {
return recompute(newState.doc, value.query, value.options, value.current);
}
return value;
},
},
props: {
decorations(state) {
return searchKey.getState(state)?.decorations ?? DecorationSet.empty;
},
},
}),
];
},
addCommands() {
return {
setSearch:
(query, options) =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "search", query, options }));
return true;
},
clearSearch:
() =>
({ tr, dispatch }) => {
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "clear" }));
return true;
},
findNext:
() =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = (s.current + 1) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
findPrev:
() =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = (s.current - 1 + s.matches.length) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
goToMatch:
(index) =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const current = ((index % s.matches.length) + s.matches.length) % s.matches.length;
if (dispatch) dispatch(tr.setMeta(searchKey, { type: "nav", current }));
scrollMatchIntoView(view, s.matches[current]);
return true;
},
replaceCurrent:
(replacement) =>
({ state, dispatch, view, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
const m = s.matches[s.current];
if (dispatch) {
tr.insertText(replacement, m.from, m.to);
dispatch(tr);
const ns = searchKey.getState(view.state);
if (ns && ns.matches.length) scrollMatchIntoView(view, ns.matches[ns.current]);
}
return true;
},
replaceAllInChapter:
(replacement) =>
({ state, dispatch, tr }) => {
const s = searchKey.getState(state);
if (!s || !s.matches.length) return false;
if (dispatch) {
for (let i = s.matches.length - 1; i >= 0; i--) {
const m = s.matches[i];
tr.insertText(replacement, m.from, m.to);
}
dispatch(tr);
}
return true;
},
};
},
});
+6 -2
View File
@@ -284,9 +284,12 @@ function contentOpf(
function navXhtml(book: Book): string {
const items = book.chapters
.map((chapter, i) => {
if (chapter.noTitle && chapterKind(chapter) !== "body") return "";
const fallback = chapterKind(chapter) === "body" ? `Chapter ${bodyNumber(book.chapters, i)}` : "Untitled";
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(chapter.title || fallback)}</a></li>`;
const label = chapter.noTitle ? fallback : chapter.title || fallback;
return ` <li><a href="chapter-${i + 1}.xhtml">${esc(label)}</a></li>`;
})
.filter(Boolean)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
@@ -311,6 +314,7 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
const num = bodyNumber(book.chapters, index);
const title = chapter.title || (kind === "body" ? `Chapter ${num}` : "Untitled");
const eyebrow = kind === "body" ? `<p class="eyebrow">Chapter ${num}</p>` : "";
const heading = chapter.noTitle ? "" : `<h1>${esc(title)}</h1>`;
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
<head>
@@ -320,7 +324,7 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
<body epub:type="${kind === "body" ? "chapter" : kind === "front" ? "frontmatter" : "backmatter"}">
<header class="chapter-opener">
${eyebrow}
<h1>${esc(title)}</h1>
${heading}
</header>
${chapterBody(chapter.content, paths)}
</body>
+8 -4
View File
@@ -139,9 +139,11 @@ function preamble(book: Book): string {
align(center)[
#if info.kind == "body" {
text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
v(0.7em)
if not info.notitle { v(0.7em) }
}
#if not info.notitle {
heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
}
#heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
]
v(1.5em)
}
@@ -262,6 +264,7 @@ function normTitle(title: string): string {
function inToc(book: Book, index: number): boolean {
const chapter = book.chapters[index];
if (chapter.noTitle && chapterKind(chapter) !== "body") return false;
const title = cleanTitle(chapter.title) || "Untitled";
return !(chapterKind(chapter) === "front" && normTitle(title) === normTitle(book.metadata.title));
}
@@ -270,8 +273,9 @@ function openerCall(book: Book, index: number): string {
const chapter = book.chapters[index];
const kind = chapterKind(chapter);
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
const title = cleanTitle(chapter.title) || "Untitled";
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, toc: ${inToc(book, index)}))`;
const notitle = !!chapter.noTitle;
const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled";
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
}
export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
+6
View File
@@ -70,6 +70,7 @@ export interface Chapter {
content: JSONContent;
updatedAt: number;
kind?: ChapterKind;
noTitle?: boolean;
}
export interface PageType {
@@ -130,6 +131,11 @@ export function createPage(group: "front" | "back", title: string): Chapter {
return { id: crypto.randomUUID(), title, content: emptyDoc(), kind: group, updatedAt: Date.now() };
}
export function cloneChapter(chapter: Chapter): Chapter {
const title = chapter.noTitle || !chapter.title ? chapter.title : `${chapter.title} (copy)`;
return { ...chapter, id: crypto.randomUUID(), title, content: structuredClone(chapter.content), updatedAt: Date.now() };
}
export function createCover(): Cover {
return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink };
}
+112
View File
@@ -0,0 +1,112 @@
import { invoke } from "@tauri-apps/api/core";
import type { Node as PMNode } from "@tiptap/pm/model";
import { isDesktop } from "./ipc";
import type { ProofIssue } from "./editor/proofing";
export interface Issue {
start: number;
end: number;
kind: "spelling" | "grammar";
category: string;
message: string;
suggestions: string[];
}
export interface Segment {
from: number;
text: string;
cpStart: number;
cpLen: number;
}
function codepointLength(text: string): number {
let n = 0;
for (const _ of text) n++;
return n;
}
function utf16Offset(text: string, codepoints: number): number {
let units = 0;
let count = 0;
for (const ch of text) {
if (count >= codepoints) break;
units += ch.length;
count++;
}
return units;
}
export function docText(doc: PMNode): { text: string; segments: Segment[] } {
const segments: Segment[] = [];
let text = "";
let cp = 0;
let first = true;
doc.descendants((node, pos) => {
if (!node.isTextblock) return true;
if (!first) {
text += "\n\n";
cp += 2;
}
first = false;
node.forEach((child, offset) => {
if (!child.isText) return;
const t = child.text ?? "";
const len = codepointLength(t);
segments.push({ from: pos + 1 + offset, text: t, cpStart: cp, cpLen: len });
text += t;
cp += len;
});
return false;
});
return { text, segments };
}
export function mapOffset(segments: Segment[], cpOffset: number): number {
for (const seg of segments) {
if (cpOffset < seg.cpStart) return seg.from;
if (cpOffset <= seg.cpStart + seg.cpLen) {
return seg.from + utf16Offset(seg.text, cpOffset - seg.cpStart);
}
}
const last = segments[segments.length - 1];
return last ? last.from + last.text.length : 0;
}
export async function proofText(text: string, opts: { spelling: boolean; grammar: boolean }): Promise<Issue[]> {
if (!isDesktop) return [];
return invoke<Issue[]>("proof_text", { text, spelling: opts.spelling, grammar: opts.grammar });
}
export async function rememberWord(word: string): Promise<void> {
if (!isDesktop) return;
await invoke("remember_word", { word });
}
export function issueSignature(issue: { kind: string; word: string; message: string }): string {
return `${issue.kind}|${issue.word}|${issue.message}`;
}
export async function runProof(
doc: PMNode,
opts: { spelling: boolean; grammar: boolean },
ignored: Set<string>
): Promise<ProofIssue[]> {
const { text, segments } = docText(doc);
if (!text.trim()) return [];
const issues = await proofText(text, opts);
const chars = [...text];
return issues
.map((iss) => {
const word = chars.slice(iss.start, iss.end).join("");
return {
from: mapOffset(segments, iss.start),
to: mapOffset(segments, iss.end),
kind: iss.kind,
category: iss.category,
message: iss.message,
suggestions: iss.suggestions,
word,
};
})
.filter((i) => i.to > i.from && !ignored.has(issueSignature(i)));
}
+61
View File
@@ -0,0 +1,61 @@
import type { JSONContent } from "@tiptap/core";
import { buildRegex, type SearchOptions } from "./editor/search";
function collectRuns(node: JSONContent, out: string[]) {
const children = node.content;
if (!children) return;
let run = "";
for (const child of children) {
if (child.type === "text") {
run += child.text ?? "";
} else {
if (run) {
out.push(run);
run = "";
}
collectRuns(child, out);
}
}
if (run) out.push(run);
}
export function countMatches(content: JSONContent, regex: RegExp): number {
const runs: string[] = [];
collectRuns(content, runs);
let count = 0;
for (const run of runs) {
regex.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = regex.exec(run)) !== null) {
count++;
if (m.index === regex.lastIndex) regex.lastIndex++;
}
}
return count;
}
export function replaceInContent(
content: JSONContent,
regex: RegExp,
replacement: string
): { content: JSONContent; count: number } {
let count = 0;
const walk = (node: JSONContent): JSONContent => {
if (node.type === "text" && node.text) {
regex.lastIndex = 0;
const text = node.text.replace(regex, () => {
count++;
return replacement;
});
return text === node.text ? node : { ...node, text };
}
if (node.content) return { ...node, content: node.content.map(walk) };
return node;
};
const next = walk(content);
return { content: next, count };
}
export function bookRegex(query: string, options: SearchOptions): RegExp | null {
return buildRegex(query, options);
}
+19
View File
@@ -7,6 +7,7 @@ import {
type ChapterKind,
type Cover,
chapterKind,
cloneChapter,
createChapter,
createPage,
normalizeBook,
@@ -27,8 +28,10 @@ interface BookState {
setActiveChapter: (id: string) => void;
setChapterContent: (id: string, content: JSONContent) => void;
setChapterTitle: (id: string, title: string) => void;
setChapterNoTitle: (id: string, noTitle: boolean) => void;
addChapter: () => void;
addPage: (group: "front" | "back", title: string) => void;
duplicateChapter: (id: string) => void;
deleteChapter: (id: string) => void;
moveChapter: (from: number, to: number, toKind?: ChapterKind) => void;
setMetadata: (patch: Partial<BookMetadata>) => void;
@@ -63,6 +66,12 @@ export const useBook = create<BookState>((set) => ({
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, title, updatedAt: Date.now() } : c)) } }
: {}
),
setChapterNoTitle: (id, noTitle) =>
set((s) =>
s.book
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noTitle, title: noTitle ? "" : c.title, updatedAt: Date.now() } : c)) } }
: {}
),
addChapter: () =>
set((s) => {
if (!s.book) return {};
@@ -82,6 +91,16 @@ export const useBook = create<BookState>((set) => ({
chapters.splice(insertAt, 0, page);
return { activeChapterId: page.id, dirty: true, book: { ...s.book, chapters } };
}),
duplicateChapter: (id) =>
set((s) => {
if (!s.book) return {};
const index = s.book.chapters.findIndex((c) => c.id === id);
if (index === -1) return {};
const copy = cloneChapter(s.book.chapters[index]);
const chapters = [...s.book.chapters];
chapters.splice(index + 1, 0, copy);
return { activeChapterId: copy.id, dirty: true, book: { ...s.book, chapters } };
}),
deleteChapter: (id) =>
set((s) => {
if (!s.book) return {};
+26
View File
@@ -0,0 +1,26 @@
import { create } from "zustand";
interface ProofingState {
spelling: boolean;
grammar: boolean;
ignored: Set<string>;
toggleSpelling: () => void;
toggleGrammar: () => void;
ignore: (signature: string) => void;
resetIgnored: () => void;
}
export const useProofing = create<ProofingState>((set) => ({
spelling: true,
grammar: false,
ignored: new Set(),
toggleSpelling: () => set((s) => ({ spelling: !s.spelling })),
toggleGrammar: () => set((s) => ({ grammar: !s.grammar })),
ignore: (signature) =>
set((s) => {
const ignored = new Set(s.ignored);
ignored.add(signature);
return { ignored };
}),
resetIgnored: () => set({ ignored: new Set() }),
}));
+371
View File
@@ -80,6 +80,8 @@ button {
.icon-btn[data-on="true"] {
color: var(--accent);
background: var(--accent-wash);
box-shadow: inset 0 0 0 1px var(--line-strong);
}
.dirty-dot {
@@ -355,12 +357,18 @@ body.resizing {
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.row-menu-item:hover {
background: var(--accent-wash);
color: var(--ink);
}
.row-menu-item.danger {
color: var(--danger);
}
.row-menu-item.danger:hover {
background: var(--danger-wash);
color: var(--danger);
}
.add-chapter {
@@ -628,6 +636,30 @@ body.resizing {
color: var(--ink-faint);
}
.chapter-title-input:disabled {
color: var(--ink-faint);
-webkit-text-fill-color: var(--ink-faint);
}
.no-title-check {
display: inline-flex;
align-items: center;
gap: 7px;
margin-top: 14px;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-faint);
cursor: pointer;
user-select: none;
}
.no-title-check input {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--ink-soft);
}
.editor-host,
.ProseMirror {
outline: none;
@@ -1621,3 +1653,342 @@ body.resizing {
color: var(--accent);
background: var(--accent-wash);
}
.find-bar {
position: fixed;
top: 54px;
right: 18px;
z-index: 30;
display: flex;
align-items: flex-start;
gap: 4px;
padding: 7px;
background: var(--glass);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-pop);
animation: find-drop 140ms var(--ease);
}
@keyframes find-drop {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.find-expand {
flex: none;
width: 20px;
align-self: stretch;
display: grid;
place-items: center;
border-radius: var(--r-sm);
color: var(--ink-faint);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.find-expand:hover {
background: var(--accent-wash);
color: var(--ink);
}
.find-stack {
display: flex;
flex-direction: column;
gap: 6px;
}
.find-row {
display: flex;
align-items: center;
gap: 4px;
}
.find-input {
width: 196px;
height: 28px;
padding: 0 10px;
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--raised);
color: var(--ink);
font-family: var(--font-ui);
font-size: var(--t-2);
}
.find-input::placeholder {
color: var(--ink-faint);
}
.find-input:focus {
outline: none;
border-color: var(--accent);
}
.find-count {
min-width: 66px;
padding: 0 4px;
font-size: var(--t-1);
color: var(--ink-faint);
white-space: nowrap;
text-align: right;
}
.find-btn {
width: 26px;
height: 26px;
flex: none;
display: grid;
place-items: center;
border-radius: var(--r-sm);
color: var(--ink-soft);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.find-btn:hover:not(:disabled) {
background: var(--accent-wash);
color: var(--ink);
}
.find-btn:disabled {
opacity: 0.4;
cursor: default;
}
.find-toggle {
height: 26px;
min-width: 28px;
padding: 0 6px;
display: grid;
place-items: center;
border-radius: var(--r-sm);
color: var(--ink-soft);
font-family: var(--font-ui);
font-size: var(--t-2);
font-weight: 600;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.find-toggle:hover {
background: var(--accent-wash);
color: var(--ink);
}
.find-toggle[data-on="true"] {
background: var(--accent);
color: var(--accent-contrast);
}
.find-ww {
text-decoration: underline;
text-underline-offset: 2px;
}
.find-action {
height: 28px;
padding: 0 12px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink-soft);
font-family: var(--font-ui);
font-size: var(--t-2);
font-weight: 600;
white-space: nowrap;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.find-action:hover:not(:disabled) {
background: var(--accent);
color: var(--accent-contrast);
}
.find-action:disabled {
opacity: 0.4;
cursor: default;
}
.find-scope {
height: 28px;
padding: 0 10px;
border-radius: var(--r-sm);
border: 1px solid var(--line-strong);
color: var(--ink-soft);
font-family: var(--font-ui);
font-size: var(--t-1);
white-space: nowrap;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.find-scope:hover {
background: var(--accent-wash);
color: var(--ink);
}
.search-match {
background: var(--accent-wash);
border-radius: 2px;
}
.search-match-current {
background: var(--selection);
border-radius: 2px;
box-shadow: 0 0 0 1.5px var(--accent);
}
.proof-mark {
cursor: pointer;
border-radius: 2px;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
transition: filter 120ms var(--ease);
}
.proof-mark.sev-error {
background: rgba(180, 69, 58, 0.13);
text-decoration-color: #b4453a;
}
.proof-mark.sev-warn {
background: rgba(168, 119, 24, 0.16);
text-decoration-color: #9c6e16;
}
.proof-mark.sev-suggest {
background: rgba(47, 110, 79, 0.13);
text-decoration-color: #2f6e4f;
}
.proof-mark:hover {
filter: brightness(0.96);
}
[data-theme="dark"] .proof-mark.sev-error {
background: rgba(217, 122, 111, 0.2);
text-decoration-color: #d97a6f;
}
[data-theme="dark"] .proof-mark.sev-warn {
background: rgba(217, 164, 65, 0.2);
text-decoration-color: #d9a441;
}
[data-theme="dark"] .proof-mark.sev-suggest {
background: rgba(111, 174, 132, 0.2);
text-decoration-color: #6fae84;
}
[data-theme="dark"] .proof-mark:hover {
filter: brightness(1.18);
}
.proof-pop {
position: fixed;
z-index: 50;
padding: 8px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow-pop);
}
.proof-pop-kind {
display: inline-block;
margin: 0 0 6px 2px;
padding: 2px 8px;
border-radius: 999px;
font-family: var(--font-ui);
font-size: var(--t-1);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.proof-pop-kind.sev-error {
background: rgba(180, 69, 58, 0.14);
color: var(--danger-ink);
}
.proof-pop-kind.sev-warn {
background: rgba(168, 119, 24, 0.16);
color: #8a6012;
}
.proof-pop-kind.sev-suggest {
background: rgba(47, 110, 79, 0.14);
color: #2c6048;
}
[data-theme="dark"] .proof-pop-kind.sev-error {
color: #e58e84;
}
[data-theme="dark"] .proof-pop-kind.sev-warn {
color: #e0bb6b;
}
[data-theme="dark"] .proof-pop-kind.sev-suggest {
color: #8fc6a3;
}
.proof-pop-msg {
padding: 2px 4px 8px;
font-family: var(--font-ui);
font-size: var(--t-2);
color: var(--ink-soft);
line-height: 1.4;
}
.proof-pop-suggestions {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.proof-suggestion {
padding: 5px 10px;
border-radius: var(--r-sm);
background: var(--accent-wash);
color: var(--ink);
font-family: var(--font-ui);
font-size: var(--t-2);
font-weight: 600;
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.proof-suggestion:hover {
background: var(--accent);
color: var(--accent-contrast);
}
.proof-pop-actions {
display: flex;
gap: 4px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--line);
}
.proof-action {
flex: 1;
padding: 6px 8px;
border-radius: var(--r-sm);
font-family: var(--font-ui);
font-size: var(--t-1);
color: var(--ink-soft);
transition: background 120ms var(--ease), color 120ms var(--ease);
}
.proof-action:hover {
background: var(--accent-wash);
color: var(--ink);
}
+31
View File
@@ -0,0 +1,31 @@
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { ask, message } from "@tauri-apps/plugin-dialog";
let running = false;
export async function checkForUpdates(silent: boolean) {
if (running) return;
running = true;
try {
const update = await check();
if (!update) {
if (!silent) await message("margin is up to date.", { title: "Check for Updates" });
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",
});
if (!install) return;
await update.downloadAndInstall();
await relaunch();
} catch (err) {
if (!silent) {
await message(`Could not check for updates.\n\n${err}`, { title: "Update Failed", kind: "error" });
}
} finally {
running = false;
}
}