fix(editor): persist per-chapter cursor and scroll across restarts

Positions were kept in an in-memory Map, so they were lost on quit and
the cursor jumped to the top on reopen. Store them in localStorage
(keyed by book + chapter, like theme/width) and restore on app open,
not just on in-session chapter switches. Also save the current chapter
continuously (debounced on selection + scroll) so closing the app
records where you left off, and re-apply scroll after fonts load.
This commit is contained in:
pj committed 2026-06-22 12:09:34 -04:00
1 parent 39609fbd09
commit 8b83f80d52
3 files changed
+66 -21

No files matched your search

+1
View File
@@ -263,6 +263,7 @@ export function EditorView() {
)} )}
</header> </header>
<Editor <Editor
bookId={book.id}
chapterId={chapter.id} chapterId={chapter.id}
content={chapter.content} content={chapter.content}
onChange={(content) => setChapterContent(chapter.id, content)} onChange={(content) => setChapterContent(chapter.id, content)}
+38 -21
View File
@@ -2,25 +2,20 @@ import { useEffect, useRef } from "react";
import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"; import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react";
import type { JSONContent } from "@tiptap/core"; import type { JSONContent } from "@tiptap/core";
import { editorExtensions } from "./extensions"; import { editorExtensions } from "./extensions";
import { loadPosition, savePosition } from "./positions";
interface EditorProps { interface EditorProps {
bookId: string;
chapterId: string; chapterId: string;
content: JSONContent; content: JSONContent;
onChange: (content: JSONContent) => void; onChange: (content: JSONContent) => void;
onReady: (editor: TiptapEditor | null) => void; onReady: (editor: TiptapEditor | null) => void;
} }
interface ChapterPosition { export function Editor({ bookId, chapterId, content, onChange, onReady }: EditorProps) {
from: number;
to: number;
scroll: number;
}
export function Editor({ chapterId, content, onChange, onReady }: EditorProps) {
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
onChangeRef.current = onChange; onChangeRef.current = onChange;
const positions = useRef(new Map<string, ChapterPosition>());
const openChapter = useRef(chapterId); const openChapter = useRef(chapterId);
const editor = useEditor({ const editor = useEditor({
@@ -37,36 +32,58 @@ export function Editor({ chapterId, content, onChange, onReady }: EditorProps) {
useEffect(() => { useEffect(() => {
if (!editor) return; if (!editor) return;
let active = true;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null; const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
const previous = openChapter.current; const previous = openChapter.current;
const switching = previous !== chapterId; if (previous !== chapterId) {
if (switching) {
const { from, to } = editor.state.selection; const { from, to } = editor.state.selection;
positions.current.set(previous, { from, to, scroll: scroller?.scrollTop ?? 0 }); savePosition(bookId, previous, { from, to, scroll: scroller?.scrollTop ?? 0 });
} }
openChapter.current = chapterId; openChapter.current = chapterId;
editor.commands.setContent(content, { emitUpdate: false }); editor.commands.setContent(content, { emitUpdate: false });
const saved = positions.current.get(chapterId); const saved = loadPosition(bookId, chapterId);
const size = editor.state.doc.content.size; const size = editor.state.doc.content.size;
const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0; const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0;
if (switching) {
editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run(); editor.chain().setTextSelection(selection).focus(undefined, { scrollIntoView: false }).run();
} else {
editor.commands.setTextSelection(selection);
}
if (scroller) { if (scroller) {
const top = saved?.scroll ?? 0; const top = saved?.scroll ?? 0;
scroller.scrollTop = top; const restore = () => {
requestAnimationFrame(() => { if (active) scroller.scrollTop = top;
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
}, [chapterId, editor]); }, [chapterId, editor, bookId]);
useEffect(() => {
if (!editor) return;
const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null;
let timer: ReturnType<typeof setTimeout>;
const persist = () => {
clearTimeout(timer);
timer = setTimeout(() => {
const { from, to } = editor.state.selection;
savePosition(bookId, openChapter.current, { from, to, scroll: scroller?.scrollTop ?? 0 });
}, 400);
};
editor.on("selectionUpdate", persist);
scroller?.addEventListener("scroll", persist, { passive: true });
return () => {
clearTimeout(timer);
editor.off("selectionUpdate", persist);
scroller?.removeEventListener("scroll", persist);
};
}, [editor, bookId]);
return <EditorContent editor={editor} className="editor-host" />; return <EditorContent editor={editor} className="editor-host" />;
} }
+27
View File
@@ -0,0 +1,27 @@
export interface ChapterPosition {
from: number;
to: number;
scroll: number;
}
const KEY = "margin-positions";
type Store = Record<string, Record<string, ChapterPosition>>;
function readAll(): Store {
try {
return JSON.parse(localStorage.getItem(KEY) || "{}") as Store;
} catch {
return {};
}
}
export function loadPosition(bookId: string, chapterId: string): ChapterPosition | null {
return readAll()[bookId]?.[chapterId] ?? null;
}
export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void {
const all = readAll();
(all[bookId] ||= {})[chapterId] = position;
localStorage.setItem(KEY, JSON.stringify(all));
}