mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
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:
1 parent
39609fbd09
commit
8b83f80d52
3 files changed
+66
-21
No files matched your search
@@ -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
@@ -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" />;
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user