Restore scroll position after live preview reload

This commit is contained in:
snw 2026-11-07 18:53:18 +03:00
parent b36009bfc0
commit 6afc48778e
5 changed files with 46 additions and 9 deletions

View file

@ -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 заново выполняет рендер всех страниц.

View file

@ -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:

View file

@ -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();
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;
}
} catch (_) { /* local preview may stop */ }
}
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);

View file

@ -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) {

View file

@ -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