3 Commits
Author SHA1 Message Date
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
16 changed files with 328 additions and 87 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.13",
"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.11"
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.13"
description = "A Tauri App" description = "A Tauri App"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+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.13",
"identifier": "studio.margin.app", "identifier": "studio.margin.app",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
+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>
+86 -44
View File
@@ -44,6 +44,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 +86,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();
@@ -120,11 +135,36 @@ export function EditorView() {
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") {
e.preventDefault(); e.preventDefault();
openFind(e.altKey); openFind(e.altKey);
} else if (e.metaKey && e.altKey && (e.key === "ArrowDown" || e.key === "ArrowRight")) {
e.preventDefault();
e.stopPropagation();
switchChapter(1);
} else if (e.metaKey && e.altKey && (e.key === "ArrowUp" || 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);
}, [saveNow, openFind, switchChapter]);
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 +217,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 +385,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">
+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" />;
} }
+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
+16
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;