fix(editor): убрать рывок восстановления позиции чтения при reload — авто-фокус заголовка (#266) #301

Merged
vvzvlad merged 1 commits from feat/scroll-restore-ux into develop 2026-07-03 13:50:17 +03:00
5 changed files with 129 additions and 8 deletions
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useScrollPosition } from "./use-scroll-position";
import { useScrollPosition, hasSavedReadingPosition } from "./use-scroll-position";
const KEY_PREFIX = "gitmost:scroll-position:";
@@ -372,3 +372,23 @@ describe("useScrollPosition", () => {
}).not.toThrow();
});
});
describe("hasSavedReadingPosition", () => {
beforeEach(() => {
window.sessionStorage.clear();
});
it("returns false when nothing is saved for the page", () => {
expect(hasSavedReadingPosition("none")).toBe(false);
});
it("returns false when the saved value is 0 (page stays at the top)", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}zero`, "0");
expect(hasSavedReadingPosition("zero")).toBe(false);
});
it("returns true when a positive position is saved", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}deep`, "500");
expect(hasSavedReadingPosition("deep")).toBe(true);
});
});
@@ -57,6 +57,17 @@ function writeStorage(pageId: string, scrollY: number): void {
}
}
/**
* Whether a positive reading position is saved for this page — i.e. the page
* will be scrolled away from the top on load. Used by the title editor to avoid
* auto-focusing (and thus placing the caret in) the now-off-screen title.
* Returns false when nothing is saved or storage is unavailable.
*/
export function hasSavedReadingPosition(pageId: string): boolean {
const y = readStorage(pageId);
return typeof y === "number" && y > 0;
}
/**
* Persists and restores the window scroll position per page so a reader keeps
* their place across a reload (F5) or reopening the document.
@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTitleAutofocus } from "./use-title-autofocus";
const KEY_PREFIX = "gitmost:scroll-position:";
function fakeEditor(overrides = {}) {
return { isInitialized: true, commands: { focus: vi.fn() }, ...overrides } as any;
}
describe("useTitleAutofocus", () => {
beforeEach(() => {
window.sessionStorage.clear();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("skips auto-focus when a saved reading position exists", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}saved`, "500");
const editor = fakeEditor();
renderHook(() => useTitleAutofocus(editor, "saved"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
it("auto-focuses a new page (no saved position) with scrollIntoView: false", () => {
const editor = fakeEditor();
renderHook(() => useTitleAutofocus(editor, "fresh"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).toHaveBeenCalledWith("end", { scrollIntoView: false });
});
it("does not focus before initialization", () => {
const editor = fakeEditor({ isInitialized: false });
renderHook(() => useTitleAutofocus(editor, "fresh2"));
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
it("cancels the pending focus on unmount", () => {
const editor = fakeEditor();
const { unmount } = renderHook(() => useTitleAutofocus(editor, "fresh3"));
unmount();
act(() => vi.advanceTimersByTime(300));
expect(editor.commands.focus).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,45 @@
import { useEffect, useRef } from "react";
import type { Editor } from "@tiptap/react";
import { hasSavedReadingPosition } from "./use-scroll-position";
// Delay before auto-focusing the title on load — guards a tiptap init race
// ("Cannot access view['hasFocus']" if focused too early).
const TITLE_AUTOFOCUS_DELAY_MS = 300;
/**
* Auto-focus the page title shortly after mount — UNLESS a saved reading position
* will be restored (then the viewport scrolls away from the top, and focusing the
* top-of-page title would drop the caret off-screen). When it does focus, it uses
* `{ scrollIntoView: false }` so placing the caret never moves the viewport
* (tiptap's focus scrolls the focused node into view by default, which otherwise
* yanks the window to the top and fights scroll-position restoration).
*
* Extracted from TitleEditor so this exact decision is unit-testable.
*
* CONTRACT: relies on TitleEditor remounting per page (page.tsx renders
* `<MemoizedFullEditor key={page.id}>`), so `hasSavedScrollRef` is captured fresh
* per page. It is read synchronously on first render, before any scroll-save
* handler can clobber the stored value to 0 — matching `useScrollPosition`'s own
* synchronous capture of `initialTargetRef`.
*/
export function useTitleAutofocus(
titleEditor: Editor | null,
pageId: string,
): void {
const hasSavedScrollRef = useRef<boolean | null>(null);
if (hasSavedScrollRef.current === null) {
hasSavedScrollRef.current = hasSavedReadingPosition(pageId);
}
useEffect(() => {
if (hasSavedScrollRef.current) return;
const timer = setTimeout(() => {
// guard against "Cannot access view['hasFocus']" before init
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end", { scrollIntoView: false });
}, TITLE_AUTOFOCUS_DELAY_MS);
// Clear the pending focus if the editor changes or the component unmounts
// (also fixes the previously-uncancelled timer).
return () => clearTimeout(timer);
}, [titleEditor]);
}
@@ -28,6 +28,7 @@ import localEmitter from "@/lib/local-emitter.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { searchSpotlight } from "@/features/search/constants.ts";
import { platformModifierKey } from "@/lib";
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
export interface TitleEditorProps {
pageId: string;
@@ -167,13 +168,7 @@ export function TitleEditor({
}
}, [pageId, title, titleEditor]);
useEffect(() => {
setTimeout(() => {
// guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end");
}, 300);
}, [titleEditor]);
useTitleAutofocus(titleEditor, pageId);
useEffect(() => {
return () => {