From 6afc48778e9eff93997cedd286b2307f82eae088 Mon Sep 17 00:00:00 2001 From: snw Date: Sat, 7 Nov 2026 18:53:18 +0300 Subject: [PATCH] Restore scroll position after live preview reload --- README.md | 2 +- docslib/server.py | 2 +- docslib/templates/client.js | 44 ++++++++++++++++++++++++++++++++----- docslib/templates/loader.js | 3 ++- tests/test_server.py | 4 +++- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index da2ed00..98857a7 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ PrintFullDocumentation(); ## Live-preview -В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами страниц, импортированными модулями с функциями `@Reactive`, а также за системными и пользовательскими CSS/JS. При изменении он пересобирает manifest; клиент сохраняет его и перезагружается с новым runtime. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер. +В live-режиме браузер опрашивает `/live`. Сервер следит за файлами страниц, импортированными модулями с функциями `@Reactive`, а также за системными и пользовательскими CSS/JS. После успешной пересборки браузер загружает новый HTML и восстанавливает позицию прокрутки открытой страницы в пикселях без анимации. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер. Используйте `@Reactive` для обычных функций уровня модуля, например для локализации или общих значений. Декоратор сохраняет аргументы и возвращаемое значение функции. При редактировании её файла live preview заново выполняет рендер всех страниц. diff --git a/docslib/server.py b/docslib/server.py index 43b85db..d870bad 100644 --- a/docslib/server.py +++ b/docslib/server.py @@ -167,7 +167,7 @@ def create_app( @app.get("/", include_in_schema=False) @app.get("/index.html", response_class=HTMLResponse) def index() -> HTMLResponse: - return HTMLResponse(site.result.html) + return HTMLResponse(site.result.html, headers=NO_CACHE_HEADERS if live_preview else None) @app.get("/manifest.json", response_class=JSONResponse) def manifest() -> JSONResponse: diff --git a/docslib/templates/client.js b/docslib/templates/client.js index 555fa01..157e5b8 100644 --- a/docslib/templates/client.js +++ b/docslib/templates/client.js @@ -57,6 +57,7 @@ let lastPlaceholderPage = -1; let customScriptLoaded = false; let lastSyncedRevision = String(manifest.revision || ""); + let liveReloading = false; let pageTransitionId = 0; let pageAnimation = null; let skipNextPageAnimation = false; @@ -838,21 +839,54 @@ } async function pollLive() { + if (liveReloading) return; try { const response = await fetch(config.liveUrl, { cache: "no-store" }); if (!response.ok) return; const live = await response.json(); - if (live.revision && live.revision !== lastSyncedRevision) { - const changed = await syncManifest(); - if (changed) location.reload(); - } - } catch (_) { /* local preview may stop */ } + if (liveReloading || !live.revision) return; + const revisionChanged = live.revision !== manifest.revision; + if (!revisionChanged && live.updated_at === manifest.updated_at) return; + liveReloading = true; + if (revisionChanged) await syncManifest(); + const docslibLiveScroll = { page: currentNumber, top: elements.pageSurface.scrollTop }; + try { + history.replaceState({ ...history.state, docslibLiveScroll }, ""); + } catch (_) { /* reload even when history state is unavailable */ } + location.reload(); + } catch (_) { + liveReloading = false; + } + } + + async function restoreLiveScroll() { + history.scrollRestoration = "manual"; + if (!history.state?.docslibLiveScroll) return; + const { docslibLiveScroll, ...state } = history.state; + history.replaceState(state, ""); + const { page, top } = docslibLiveScroll; + if (page !== currentNumber || !Number.isFinite(top) || top <= 0) return; + + const surface = elements.pageSurface; + surface.scrollTo({ top, behavior: "instant" }); + if (surface.scrollTop >= top - 1) return; + + // Images can contribute to the scroll height only after their dimensions are known. + const pendingImages = [...elements.content.querySelectorAll("img")].filter((image) => !image.complete); + if (!pendingImages.length) return; + pendingImages.forEach((image) => { image.loading = "eager"; }); + await Promise.race([ + waitForPrintAssets(elements.content), + new Promise((resolve) => setTimeout(resolve, 300)), + ]); + if (page === currentNumber) surface.scrollTo({ top, behavior: "instant" }); } async function start() { applyTheme(localStorage.getItem("docslib-theme") || "light"); loadAccessibilityPreferences(); renderAll(); + if (config.livePreview) await restoreLiveScroll(); announceRendered(); syncManifest(); if (config.livePreview) setInterval(pollLive, 1200); diff --git a/docslib/templates/loader.js b/docslib/templates/loader.js index 55ba7b7..6d31278 100644 --- a/docslib/templates/loader.js +++ b/docslib/templates/loader.js @@ -19,7 +19,7 @@ ); } - const dbPromise = new Promise((resolve, reject) => { + const dbPromise = config.livePreview ? null : new Promise((resolve, reject) => { if (!window.indexedDB) return reject(new Error("IndexedDB")); const request = indexedDB.open("docslib-cache", 1); request.onupgradeneeded = () => { @@ -51,6 +51,7 @@ } async function selectManifest() { + if (config.livePreview) return embedded; try { const cached = await cacheGet(); if (isManifest(cached) && cached.schema_version === embedded.schema_version) { diff --git a/tests/test_server.py b/tests/test_server.py index 953650a..57aea5f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -26,7 +26,9 @@ def test_loader_renders_indexeddb_before_background_sync(): assert "return cached;" in loader assert "await cachePut(embedded);" in loader assert "fetch(" not in loader - assert "renderAll();\n announceRendered();\n syncManifest();" in client + assert 'if (config.livePreview) return embedded;' in loader + assert client.index(" renderAll();") < client.index(" announceRendered();") + assert " announceRendered();\n syncManifest();" in client assert "manifest = remote" not in client