[feature][editor] Сохранение позиции чтения (scroll position) при перезагрузке страницы #266

Closed
opened 2026-06-30 11:25:49 +03:00 by vvzvlad · 0 comments
Owner

Проблема / мотивация

Сейчас при перезагрузке страницы документа (F5) или повторном открытии страницы окно
прокручивается в самое начало. Для длинных страниц читатель теряет место, на котором
остановился, и вынужден прокручивать заново. Хочется, чтобы позиция чтения (scroll
position) сохранялась и восстанавливалась.

Как сейчас устроено (результаты исследования)

  • Документ прокручивается вместе с окном (window), а не внутренним контейнером с
    overflow. Подтверждение: оглавление использует window.scrollY / window.scrollTo
    в apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx
    (строки ~61–65). В app-shell.module.css нет своего скролл-контейнера — Mantine
    AppShell скроллит body/window.
  • Чтение/редактирование страницы рендерит apps/client/src/features/editor/page-editor.tsx.
    Контент грузится асинхронно: сначала показывается статический рендер из кэша
    (showStatic = true), затем, после синхронизации collab-провайдера (Yjs/Hocuspocus),
    происходит переключение на «живой» редактор (showStatic = false). Высота документа
    становится финальной только после этого переключения.
  • Уже есть хук apps/client/src/features/editor/hooks/use-editor-scroll.ts, который
    скроллит к якорю по #hash в URL при загрузке (handleScrollTo вызывается в
    onCreate редактора). Новая логика восстановления позиции обязана уступать этому
    механизму: если в URL есть #hash — приоритет у якоря, позицию из хранилища не
    восстанавливаем.

Предлагаемое решение

Новый хук useScrollPosition(pageId) в
apps/client/src/features/editor/hooks/use-scroll-position.ts, подключаемый внутри
page-editor.tsx. Хук:

  1. Сохраняет window.scrollY для текущей страницы (ключ — pageId):
    • по событию scroll (throttle ~250 мс),
    • по pagehide и visibilitychange (на момент перезагрузки/закрытия/смены вкладки),
    • в cleanup-эффекте при размонтировании (SPA-навигация на другую страницу).
  2. Восстанавливает позицию один раз, когда «живой» контент уже размещён
    (!showStatic && editor):
    • если в URL есть #hash — выходим (приоритет у якорной прокрутки);
    • читаем сохранённое значение, захваченное синхронно при монтировании (до того,
      как обработчики успеют перезаписать его свежим 0);
    • поллим высоту документа и скроллим, как только
      documentElement.scrollHeight - innerHeight >= targetY (контент догрузился), либо по
      таймауту (~5 с) скроллим к максимально достижимой позиции;
    • повторные вызовы игнорируются (guard через ref — восстановление ровно один раз на
      страницу).

Где именно подключить

В page-editor.tsx:

  • const { restoreScrollPosition } = useScrollPosition(pageId);
  • эффект восстановления:
    // Restore the saved reading position once the live content is laid out.
    useEffect(() => {
      if (!showStatic && editor) restoreScrollPosition();
    }, [showStatic, editor, restoreScrollPosition]);
    
    (onCreate уже вызывает handleScrollTo(editor) для якоря — конфликта нет, т.к.
    методы взаимоисключающие: якорь работает только при наличии #hash, восстановление —
    только при его отсутствии.)

Эскиз хука (иллюстративно; все комментарии в коде — на английском)

const STORAGE_PREFIX = "gitmost:scroll-position:";
const SAVE_THROTTLE_MS = 250;
const MAX_RESTORE_WAIT_MS = 5000;
const RESTORE_POLL_MS = 100;

export const useScrollPosition = (pageId: string) => {
  const pageIdRef = useRef(pageId);
  pageIdRef.current = pageId;
  const hasRestoredRef = useRef(false);

  // Capture the value saved by a previous session synchronously, before any
  // scroll/visibility handler can overwrite it with the fresh 0.
  const initialTargetRef = useRef<number | null>(null);
  if (initialTargetRef.current === null) {
    const raw = readStorage(pageId);          // sessionStorage.getItem(key)
    const y = raw == null ? NaN : Number(raw);
    initialTargetRef.current = Number.isFinite(y) ? y : -1; // -1 = nothing saved
  }

  const save = useCallback(() => {
    writeStorage(pageIdRef.current, Math.round(window.scrollY));
  }, []);

  useEffect(() => {
    let timer: number | null = null;
    const onScroll = () => {
      if (timer !== null) return;
      timer = window.setTimeout(() => { timer = null; save(); }, SAVE_THROTTLE_MS);
    };
    const onHide = () => save();
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("pagehide", onHide);
    document.addEventListener("visibilitychange", onHide);
    return () => {
      if (timer !== null) window.clearTimeout(timer);
      save();                                   // persist on SPA navigation away
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("pagehide", onHide);
      document.removeEventListener("visibilitychange", onHide);
    };
  }, [save]);

  const restoreScrollPosition = useCallback(() => {
    if (hasRestoredRef.current) return;
    if (window.location.hash) return;           // hash anchor takes priority
    const targetY = initialTargetRef.current ?? -1;
    if (targetY <= 0) return;
    hasRestoredRef.current = true;
    const start = Date.now();
    const tick = () => {
      const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
      if (maxScroll >= targetY || Date.now() - start >= MAX_RESTORE_WAIT_MS) {
        window.scrollTo({ top: Math.min(targetY, Math.max(maxScroll, 0)), behavior: "auto" });
        return;
      }
      window.setTimeout(tick, RESTORE_POLL_MS);
    };
    tick();
  }, []);

  return { restoreScrollPosition };
};

Решение по хранилищу

Рекомендуется sessionStorage как значение по умолчанию (MVP):

  • точно отвечает формулировке «при перезагрузке страницы»: переживает F5 в той же
    вкладке, очищается при её закрытии;
  • самоограничивается (не накапливает записи, не требует логики вытеснения);
  • никаких приватных данных в долгом хранилище.

Альтернатива — localStorage (позиция переживает закрытие/перезапуск браузера,
удобнее для «продолжить чтение»). Минус — рост хранилища, поэтому потребуется единая
JSON-карта { pageId: { y, ts } } с LRU-ограничением (например, последние 50 записей по
ts). Предлагается оставить как опциональное улучшение/follow-up, если потребуется.

Краевые случаи

  • #hash в URL — не восстанавливаем позицию (приоритет у якоря).
  • Асинхронная загрузка контента — поллинг высоты + таймаут, чтобы не скроллить в
    «пустой» документ до его размещения.
  • Переключение showStatic → live — восстанавливаем только после !showStatic,
    чтобы пересборка DOM «живого» редактора не сбросила прокрутку.
  • Гонка «свежий 0 затирает сохранённое» — значение захватывается синхронно при
    монтировании в initialTargetRef.
  • Кросс-страничное «протекание»FullEditor/PageEditor монтируются с
    key={page.id}, поэтому при смене страницы инстанс пересоздаётся; pageIdRef старого
    инстанса не меняется. Дополнительно ключ хранилища привязан к pageId.
  • Хранилище недоступно (privacy-режим/квота) — все обращения обёрнуты в try/catch и
    тихо игнорируются (фича не критична).
  • Контент стал короче (страницу обрезали) — скроллим к min(targetY, maxScroll).
  • Offline / collab не подключился (страница осталась в showStatic) — в MVP
    восстановление не сработает (нет перехода в !showStatic); приемлемо. При желании —
    отдельный fallback по таймауту.

Затрагиваемые файлы

  • apps/client/src/features/editor/hooks/use-scroll-position.tsновый хук.
  • apps/client/src/features/editor/page-editor.tsx — подключение хука + эффект
    восстановления.

Критерии приёмки (DoD)

  • Прокрутил длинную страницу, нажал F5 — позиция восстанавливается на ту же область
    (с точностью до высоты, появившейся при догрузке контента).
  • Открытие по ссылке с #hash по-прежнему скроллит к якорю, позиция из хранилища не
    перебивает якорь.
  • Переход на другую страницу и обратно не «протаскивает» позицию между разными
    страницами.
  • Нет видимого «дёргания»: восстановление происходит после размещения контента,
    од��оразово.
  • Отсутствие/ошибки Storage не валят страницу.
  • Все комментарии в коде — на английском.

Риски

  • Двойной скролл/конфликт с handleScrollTo — снимается взаимоисключающими условиями
    (#hash).
  • Неверная высота при медленной догрузке — снимается поллингом высоты + таймаутом.
## Проблема / мотивация Сейчас при перезагрузке страницы документа (F5) или повторном открытии страницы окно прокручивается в самое начало. Для длинных страниц читатель теряет место, на котором остановился, и вынужден прокручивать заново. Хочется, чтобы позиция чтения (scroll position) сохранялась и восстанавливалась. ## Как сейчас устроено (результаты исследования) - Документ прокручивается вместе с **окном** (`window`), а не внутренним контейнером с `overflow`. Подтверждение: оглавление использует `window.scrollY` / `window.scrollTo` в `apps/client/src/features/editor/components/table-of-contents/table-of-contents.tsx` (строки ~61–65). В `app-shell.module.css` нет своего скролл-контейнера — Mantine `AppShell` скроллит body/window. - Чтение/редактирование страницы рендерит `apps/client/src/features/editor/page-editor.tsx`. Контент грузится **асинхронно**: сначала показывается статический рендер из кэша (`showStatic = true`), затем, после синхронизации collab-провайдера (Yjs/Hocuspocus), происходит переключение на «живой» редактор (`showStatic = false`). Высота документа становится финальной только после этого переключения. - Уже есть хук `apps/client/src/features/editor/hooks/use-editor-scroll.ts`, который скроллит к якорю по `#hash` в URL при загрузке (`handleScrollTo` вызывается в `onCreate` редактора). Новая логика восстановления позиции **обязана уступать** этому механизму: если в URL есть `#hash` — приоритет у якоря, позицию из хранилища не восстанавливаем. ## Предлагаемое решение Новый хук `useScrollPosition(pageId)` в `apps/client/src/features/editor/hooks/use-scroll-position.ts`, подключаемый внутри `page-editor.tsx`. Хук: 1. **Сохраняет** `window.scrollY` для текущей страницы (ключ — `pageId`): - по событию `scroll` (throttle ~250 мс), - по `pagehide` и `visibilitychange` (на момент перезагрузки/закрытия/смены вкладки), - в cleanup-эффекте при размонтировании (SPA-навигация на другую страницу). 2. **Восстанавливает** позицию один раз, когда «живой» контент уже размещён (`!showStatic && editor`): - если в URL есть `#hash` — выходим (приоритет у якорной прокрутки); - читаем сохранённое значение, **захваченное синхронно при монтировании** (до того, как обработчики успеют перезаписать его свежим `0`); - поллим высоту документа и скроллим, как только `documentElement.scrollHeight - innerHeight >= targetY` (контент догрузился), либо по таймауту (~5 с) скроллим к максимально достижимой позиции; - повторные вызовы игнорируются (guard через ref — восстановление ровно один раз на страницу). ### Где именно подключить В `page-editor.tsx`: - `const { restoreScrollPosition } = useScrollPosition(pageId);` - эффект восстановления: ```ts // Restore the saved reading position once the live content is laid out. useEffect(() => { if (!showStatic && editor) restoreScrollPosition(); }, [showStatic, editor, restoreScrollPosition]); ``` (`onCreate` уже вызывает `handleScrollTo(editor)` для якоря — конфликта нет, т.к. методы взаимоисключающие: якорь работает только при наличии `#hash`, восстановление — только при его отсутствии.) ### Эскиз хука (иллюстративно; все комментарии в коде — на английском) ```ts const STORAGE_PREFIX = "gitmost:scroll-position:"; const SAVE_THROTTLE_MS = 250; const MAX_RESTORE_WAIT_MS = 5000; const RESTORE_POLL_MS = 100; export const useScrollPosition = (pageId: string) => { const pageIdRef = useRef(pageId); pageIdRef.current = pageId; const hasRestoredRef = useRef(false); // Capture the value saved by a previous session synchronously, before any // scroll/visibility handler can overwrite it with the fresh 0. const initialTargetRef = useRef<number | null>(null); if (initialTargetRef.current === null) { const raw = readStorage(pageId); // sessionStorage.getItem(key) const y = raw == null ? NaN : Number(raw); initialTargetRef.current = Number.isFinite(y) ? y : -1; // -1 = nothing saved } const save = useCallback(() => { writeStorage(pageIdRef.current, Math.round(window.scrollY)); }, []); useEffect(() => { let timer: number | null = null; const onScroll = () => { if (timer !== null) return; timer = window.setTimeout(() => { timer = null; save(); }, SAVE_THROTTLE_MS); }; const onHide = () => save(); window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("pagehide", onHide); document.addEventListener("visibilitychange", onHide); return () => { if (timer !== null) window.clearTimeout(timer); save(); // persist on SPA navigation away window.removeEventListener("scroll", onScroll); window.removeEventListener("pagehide", onHide); document.removeEventListener("visibilitychange", onHide); }; }, [save]); const restoreScrollPosition = useCallback(() => { if (hasRestoredRef.current) return; if (window.location.hash) return; // hash anchor takes priority const targetY = initialTargetRef.current ?? -1; if (targetY <= 0) return; hasRestoredRef.current = true; const start = Date.now(); const tick = () => { const maxScroll = document.documentElement.scrollHeight - window.innerHeight; if (maxScroll >= targetY || Date.now() - start >= MAX_RESTORE_WAIT_MS) { window.scrollTo({ top: Math.min(targetY, Math.max(maxScroll, 0)), behavior: "auto" }); return; } window.setTimeout(tick, RESTORE_POLL_MS); }; tick(); }, []); return { restoreScrollPosition }; }; ``` ## Решение по хранилищу Рекомендуется **`sessionStorage`** как значение по умолчанию (MVP): - точно отвечает формулировке «при перезагрузке страницы»: переживает F5 в той же вкладке, очищается при её закрытии; - самоограничивается (не накапливает записи, не требует логики вытеснения); - никаких приватных данных в долгом хранилище. Альтернатива — **`localStorage`** (позиция переживает закрытие/перезапуск браузера, удобнее для «продолжить чтение»). Минус — рост хранилища, поэтому потребуется единая JSON-карта `{ pageId: { y, ts } }` с LRU-ограничением (например, последние 50 записей по `ts`). Предлагается оставить как опциональное улучшение/follow-up, если потребуется. ## Краевые случаи - **`#hash` в URL** — не восстанавливаем позицию (приоритет у якоря). - **Асинхронная загрузка контента** — поллинг высоты + таймаут, чтобы не скроллить в «пустой» документ до его размещения. - **Переключение `showStatic → live`** — восстанавливаем только после `!showStatic`, чтобы пересборка DOM «живого» редактора не сбросила прокрутку. - **Гонка «свежий 0 затирает сохранённое»** — значение захватывается синхронно при монтировании в `initialTargetRef`. - **Кросс-страничное «протекание»** — `FullEditor`/`PageEditor` монтируются с `key={page.id}`, поэтому при смене страницы инстанс пересоздаётся; `pageIdRef` старого инстанса не меняется. Дополнительно ключ хранилища привязан к `pageId`. - **Хранилище недоступно** (privacy-режим/квота) — все обращения обёрнуты в try/catch и тихо игнорируются (фича не критична). - **Контент стал короче** (страницу обрезали) — скроллим к `min(targetY, maxScroll)`. - **Offline / collab не подключился** (страница осталась в `showStatic`) — в MVP восстановление не сработает (нет перехода в `!showStatic`); приемлемо. При желании — отдельный fallback по таймауту. ## Затрагиваемые файлы - `apps/client/src/features/editor/hooks/use-scroll-position.ts` — **новый** хук. - `apps/client/src/features/editor/page-editor.tsx` — подключение хука + эффект восстановления. ## Критерии приёмки (DoD) - [ ] Прокрутил длинную страницу, нажал F5 — позиция восстанавливается на ту же область (с точностью до высоты, появившейся при догрузке контента). - [ ] Открытие по ссылке с `#hash` по-прежнему скроллит к якорю, позиция из хранилища не перебивает якорь. - [ ] Переход на другую страницу и обратно не «протаскивает» позицию между разными страницами. - [ ] Нет видимого «дёргания»: восстановление происходит после размещения контента, од��оразово. - [ ] Отсутствие/ошибки `Storage` не валят страницу. - [ ] Все комментарии в коде — на английском. ## Риски - Двойной скролл/конфликт с `handleScrollTo` — снимается взаимоисключающими условиями (`#hash`). - Неверная высота при медленной догрузке — снимается поллингом высоты + таймаутом.
vvzvlad added the feature label 2026-06-30 11:25:49 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#266