fix(preview): restore continuous document scrolling

This commit is contained in:
caoqianming 2026-08-10 09:16:01 +08:00
parent 8d741e4141
commit 2187c7f0d0
7 changed files with 136 additions and 44 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.63.3 — 2026-08-10
- PDF 和 PPT 预览恢复连续滚动阅读,无需反复点击上一页、下一页;页码跳转和缩放仍可使用,长文档也会按浏览位置逐页加载。
## 0.63.2 — 2026-08-10 ## 0.63.2 — 2026-08-10
- App 内的 PDF 和 PPT 预览不再依赖 WebView 自带的 PDF 查看能力现可直接翻页、适宽和缩放HTML 预览同步修复部分 WebView 中加载空白的问题。 - App 内的 PDF 和 PPT 预览不再依赖 WebView 自带的 PDF 查看能力现可直接翻页、适宽和缩放HTML 预览同步修复部分 WebView 中加载空白的问题。

View File

@ -311,7 +311,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
### 8.3 PPTX 前端在线预览(✅ Stage 1) ### 8.3 PPTX 前端在线预览(✅ Stage 1)
关键洞察:后端 soffice 把 PPT/PPTX 统一转成 PDF前端只维护一条 PDF 展示路径。选 LibreOffice(像素级保真、任意 pptx)不选轻量 HTML(复杂失真)/PDF→PNG(失矢量)。转换在 web host 不进沙盒;宏安全 high + 禁网 + 仅本人 user_root。2026-08 App WebView 暴露浏览器内置 PDF 插件不可用,展示层由 blob iframe 改为本地 PDF.js canvas(单页按需渲染,避免长文档一次性占满内存);Web 端与 App 共用,不新增免鉴权文件 URL。任意 HTML 仍在 opaque-origin sandbox iframe 内运行,但内容入口改为同源静态宿主页 + postMessage避开 WebView 的 `srcdoc` 空白和原生 URL 白名单误拦 `blob:`。与 `pptx_preview.py`(agent 生成期自检)分工。Stage 2 未做:常驻 listener / eager 预转。 关键洞察:后端 soffice 把 PPT/PPTX 统一转成 PDF前端只维护一条 PDF 展示路径。选 LibreOffice(像素级保真、任意 pptx)不选轻量 HTML(复杂失真)/PDF→PNG(失矢量)。转换在 web host 不进沙盒;宏安全 high + 禁网 + 仅本人 user_root。2026-08 App WebView 暴露浏览器内置 PDF 插件不可用,展示层由 blob iframe 改为本地 PDF.js canvas;页面保持纵向连续滚动,只在视口前后懒渲染并释放远处 Canvas兼顾阅读习惯与长文档内存。Web 端与 App 共用,不新增免鉴权文件 URL。任意 HTML 仍在 opaque-origin sandbox iframe 内运行,但内容入口改为同源静态宿主页 + postMessage避开 WebView 的 `srcdoc` 空白和原生 URL 白名单误拦 `blob:`。与 `pptx_preview.py`(agent 生成期自检)分工。Stage 2 未做:常驻 listener / eager 预转。
### 8.4 运维监控 / 无感更新(监控 ✅ / 换版 design) ### 8.4 运维监控 / 无感更新(监控 ✅ / 换版 design)

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-10(App WebView PDF/PPT/HTML 预览兼容,bump 0.63.2) 最后更新:2026-08-10(PDF/PPT 连续滚动预览,bump 0.63.3)
--- ---
@ -23,6 +23,8 @@
### 2026-08-10 ### 2026-08-10
- **08-10 / 0.63.3 / PDF/PPT 连续滚动预览**:PDF.js 展示由上一页/下一页驱动的单页 Canvas 恢复为纵向连续页列表,保留页码跳转、适宽和缩放;页面进入视口前后才渲染,滚远后释放 Canvas兼顾原有阅读习惯与长文档内存占用。Node 前端预览 10 项、JavaScript 语法及 diff 检查通过;无 schema、migration、HTTP API、依赖或运行方式变化未连接生产 DB。
- **08-10 / 0.63.2 / App WebView PDF/PPT/HTML 预览兼容**:PDF 与 PPT 转换结果不再通过 blob iframe 依赖浏览器内置 PDF 插件,改为本地固化 PDF.js 后按单页 Canvas 渲染,支持翻页、适宽与缩放,并在关闭或切换预览时释放 loading/render task、worker 和文档资源。HTML 保留 opaque-origin sandbox 安全边界,内容入口由兼容不稳的 `srcdoc` 改为同源静态宿主页 + postMessage避开原生 URL 白名单误拦 `blob:`App 对接文档明确白名单仅处理主 frame。Node 前端 18 项、静态资源 unittest 12 项、JavaScript/PDF.js bundle 语法及 diff 检查通过;无 schema、migration、HTTP API 或 Python 依赖变化,未连接生产 DB。 - **08-10 / 0.63.2 / App WebView PDF/PPT/HTML 预览兼容**:PDF 与 PPT 转换结果不再通过 blob iframe 依赖浏览器内置 PDF 插件,改为本地固化 PDF.js 后按单页 Canvas 渲染,支持翻页、适宽与缩放,并在关闭或切换预览时释放 loading/render task、worker 和文档资源。HTML 保留 opaque-origin sandbox 安全边界,内容入口由兼容不稳的 `srcdoc` 改为同源静态宿主页 + postMessage避开原生 URL 白名单误拦 `blob:`App 对接文档明确白名单仅处理主 frame。Node 前端 18 项、静态资源 unittest 12 项、JavaScript/PDF.js bundle 语法及 diff 检查通过;无 schema、migration、HTTP API 或 Python 依赖变化,未连接生产 DB。
### 2026-08-07 ### 2026-08-07

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.63.2" __version__ = "0.63.3"

View File

@ -82,6 +82,15 @@ test("PDF previews use the bundled PDF.js canvas renderer for WebView compatibil
assert.match(pageHtml, /\.pdf-canvas/); assert.match(pageHtml, /\.pdf-canvas/);
}); });
test("PDF previews render a continuously scrollable lazy page list", () => {
assert.match(previewJs, /class="pdf-pages-list"/);
assert.match(previewJs, /new IntersectionObserver/);
assert.match(previewJs, /root: viewportHost/);
assert.match(previewJs, /viewportHost\.addEventListener\("scroll"/);
assert.doesNotMatch(previewJs, /class="small pdf-(?:prev|next)"/);
assert.match(pageHtml, /\.pdf-pages-list\s*\{[^}]*flex-direction:\s*column/s);
});
test("assistant HTML artifacts render inline with lazy loading and an expand action", () => { test("assistant HTML artifacts render inline with lazy loading and an expand action", () => {
const mediaJs = readFileSync(new URL("../web/static/js/media.js", import.meta.url), "utf8"); const mediaJs = readFileSync(new URL("../web/static/js/media.js", import.meta.url), "utf8");
assert.match(mediaJs, /cat === "html"/); assert.match(mediaJs, /cat === "html"/);

View File

@ -1373,6 +1373,11 @@
.pdf-toolbar-spacer { flex: 1; } .pdf-toolbar-spacer { flex: 1; }
.pdf-zoom { min-width: 38px; text-align: center; font-size: 12px; color: var(--muted); } .pdf-zoom { min-width: 38px; text-align: center; font-size: 12px; color: var(--muted); }
.pdf-viewport { flex: 1; min-height: 0; overflow: auto; padding: 12px; overscroll-behavior: contain; } .pdf-viewport { flex: 1; min-height: 0; overflow: auto; padding: 12px; overscroll-behavior: contain; }
.pdf-pages-list { display: flex; flex-direction: column; align-items: center; gap: 14px; min-width: max-content; }
.pdf-page-shell {
position: relative; flex: 0 0 auto; display: flex; align-items: center; justify-content: center;
background: #fff; color: var(--muted); font-size: 12px; box-shadow: 0 2px 12px rgba(15,23,42,.18);
}
.pdf-canvas { display: block; margin: 0 auto; background: #fff; box-shadow: 0 2px 12px rgba(15,23,42,.18); } .pdf-canvas { display: block; margin: 0 auto; background: #fff; box-shadow: 0 2px 12px rgba(15,23,42,.18); }
#file-preview-modal .body pre.preview-text { #file-preview-modal .body pre.preview-text {
margin: 0; padding: 8px; background: var(--code-bg); margin: 0; padding: 8px; background: var(--code-bg);

View File

@ -354,9 +354,14 @@ function _clearPdfPreview(body) {
const state = _pdfPreviewStates.get(body); const state = _pdfPreviewStates.get(body);
if (!state) return; if (!state) return;
state.disposed = true; state.disposed = true;
if (state.renderTask) state.renderTask.cancel(); if (state.renderTasks) {
for (const task of state.renderTasks.values()) task.cancel();
state.renderTasks.clear();
}
if (state.loadingTask) state.loadingTask.destroy(); if (state.loadingTask) state.loadingTask.destroy();
if (state.resizeObserver) state.resizeObserver.disconnect(); if (state.resizeObserver) state.resizeObserver.disconnect();
if (state.pageObserver) state.pageObserver.disconnect();
if (state.scrollFrame) cancelAnimationFrame(state.scrollFrame);
if (state.onResize) window.removeEventListener("resize", state.onResize); if (state.onResize) window.removeEventListener("resize", state.onResize);
if (state.pdf) state.pdf.destroy(); if (state.pdf) state.pdf.destroy();
_pdfPreviewStates.delete(body); _pdfPreviewStates.delete(body);
@ -370,18 +375,19 @@ async function _showPdfIn(body, blob, fallbackFn) {
loadingTask: null, loadingTask: null,
pageNumber: 1, pageNumber: 1,
zoom: 1, zoom: 1,
renderTask: null, renderTasks: new Map(),
renderSeq: 0, renderSeq: 0,
pageShells: [],
pageObserver: null,
resizeObserver: null, resizeObserver: null,
onResize: null, onResize: null,
scrollFrame: null,
}; };
_pdfPreviewStates.set(body, state); _pdfPreviewStates.set(body, state);
body.className = "body pdf-preview"; body.className = "body pdf-preview";
body.innerHTML = ` body.innerHTML = `
<div class="pdf-toolbar"> <div class="pdf-toolbar">
<button class="small pdf-prev" type="button" aria-label="上一页" disabled>上一页</button>
<label> <input class="pdf-page" type="number" min="1" value="1" inputmode="numeric" disabled> / <span class="pdf-pages"></span> </label> <label> <input class="pdf-page" type="number" min="1" value="1" inputmode="numeric" disabled> / <span class="pdf-pages"></span> </label>
<button class="small pdf-next" type="button" aria-label="下一页" disabled>下一页</button>
<span class="pdf-toolbar-spacer"></span> <span class="pdf-toolbar-spacer"></span>
<button class="small pdf-zoom-out" type="button" aria-label="缩小" disabled></button> <button class="small pdf-zoom-out" type="button" aria-label="缩小" disabled></button>
<span class="pdf-zoom">适宽</span> <span class="pdf-zoom">适宽</span>
@ -393,20 +399,22 @@ async function _showPdfIn(body, blob, fallbackFn) {
const pageInput = body.querySelector(".pdf-page"); const pageInput = body.querySelector(".pdf-page");
const pagesLabel = body.querySelector(".pdf-pages"); const pagesLabel = body.querySelector(".pdf-pages");
const zoomLabel = body.querySelector(".pdf-zoom"); const zoomLabel = body.querySelector(".pdf-zoom");
const prev = body.querySelector(".pdf-prev");
const next = body.querySelector(".pdf-next");
const zoomOut = body.querySelector(".pdf-zoom-out"); const zoomOut = body.querySelector(".pdf-zoom-out");
const zoomIn = body.querySelector(".pdf-zoom-in"); const zoomIn = body.querySelector(".pdf-zoom-in");
const renderPage = async () => { const renderPage = async (pageNumber) => {
if (state.disposed || !state.pdf) return; if (state.disposed || !state.pdf) return;
const seq = ++state.renderSeq; const shell = state.pageShells[pageNumber - 1];
if (state.renderTask) state.renderTask.cancel(); if (!shell) return;
const page = await state.pdf.getPage(state.pageNumber); const renderKey = `${state.renderSeq}:${viewportHost.clientWidth}:${state.zoom}`;
if (state.disposed || seq !== state.renderSeq) return; if (shell.dataset.renderKey === renderKey || state.renderTasks.has(pageNumber)) return;
const page = await state.pdf.getPage(pageNumber);
if (state.disposed || !shell.isConnected) return;
const baseViewport = page.getViewport({ scale: 1 }); const baseViewport = page.getViewport({ scale: 1 });
const fitScale = Math.max(0.1, (viewportHost.clientWidth - 24) / baseViewport.width); const fitScale = Math.max(0.1, (viewportHost.clientWidth - 24) / baseViewport.width);
const viewport = page.getViewport({ scale: fitScale * state.zoom }); const viewport = page.getViewport({ scale: fitScale * state.zoom });
shell.dataset.baseWidth = String(baseViewport.width);
shell.dataset.baseHeight = String(baseViewport.height);
const outputScale = Math.min(window.devicePixelRatio || 1, 2); const outputScale = Math.min(window.devicePixelRatio || 1, 2);
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const context = canvas.getContext("2d"); const context = canvas.getContext("2d");
@ -415,56 +423,72 @@ async function _showPdfIn(body, blob, fallbackFn) {
canvas.height = Math.floor(viewport.height * outputScale); canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + "px"; canvas.style.width = Math.floor(viewport.width) + "px";
canvas.style.height = Math.floor(viewport.height) + "px"; canvas.style.height = Math.floor(viewport.height) + "px";
viewportHost.innerHTML = ""; shell.style.width = Math.floor(viewport.width) + "px";
viewportHost.appendChild(canvas); shell.style.height = Math.floor(viewport.height) + "px";
state.renderTask = page.render({ const renderTask = page.render({
canvasContext: context, canvasContext: context,
viewport, viewport,
transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0], transform: outputScale === 1 ? null : [outputScale, 0, 0, outputScale, 0, 0],
}); });
state.renderTasks.set(pageNumber, renderTask);
try { try {
await state.renderTask.promise; await renderTask.promise;
} catch (e) { } catch (e) {
if (e && e.name === "RenderingCancelledException") return; if (e && e.name === "RenderingCancelledException") return;
throw e; throw e;
} finally { } finally {
if (seq === state.renderSeq) state.renderTask = null; if (state.renderTasks.get(pageNumber) === renderTask) state.renderTasks.delete(pageNumber);
} }
if (state.disposed || seq !== state.renderSeq) return; if (state.disposed || renderKey !== `${state.renderSeq}:${viewportHost.clientWidth}:${state.zoom}`) return;
pageInput.value = String(state.pageNumber); shell.innerHTML = "";
prev.disabled = state.pageNumber <= 1; shell.appendChild(canvas);
next.disabled = state.pageNumber >= state.pdf.numPages; shell.dataset.renderKey = renderKey;
zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%";
viewportHost.scrollTo({ top: 0, left: 0 });
}; };
const goToPage = (pageNumber) => { const goToPage = (pageNumber) => {
if (!state.pdf) return; if (!state.pdf) return;
state.pageNumber = Math.min(state.pdf.numPages, Math.max(1, pageNumber)); state.pageNumber = Math.min(state.pdf.numPages, Math.max(1, pageNumber));
renderPage().catch((e) => { pageInput.value = String(state.pageNumber);
if (state.disposed) return; const shell = state.pageShells[state.pageNumber - 1];
if (shell) viewportHost.scrollTo({ top: Math.max(0, shell.offsetTop - 12), behavior: "smooth" });
};
pageInput.onchange = () => goToPage(parseInt(pageInput.value, 10) || 1);
const rerenderVisiblePages = () => {
state.renderSeq += 1;
for (const task of state.renderTasks.values()) task.cancel();
state.renderTasks.clear();
const availableWidth = Math.max(1, viewportHost.clientWidth - 24);
for (const shell of state.pageShells) {
shell.dataset.renderKey = "";
const baseWidth = Number(shell.dataset.baseWidth);
const baseHeight = Number(shell.dataset.baseHeight);
const scale = Math.max(0.1, availableWidth / baseWidth) * state.zoom;
shell.style.width = Math.floor(baseWidth * scale) + "px";
shell.style.height = Math.floor(baseHeight * scale) + "px";
const rect = shell.getBoundingClientRect();
const hostRect = viewportHost.getBoundingClientRect();
if (rect.bottom >= hostRect.top - hostRect.height && rect.top <= hostRect.bottom + hostRect.height) {
renderPage(Number(shell.dataset.page)).catch(handleRenderError);
}
}
};
const handleRenderError = (e) => {
if (state.disposed || (e && e.name === "RenderingCancelledException")) return;
_clearPdfPreview(body); _clearPdfPreview(body);
fallbackFn("PDF 渲染失败:" + e.message); fallbackFn("PDF 渲染失败:" + e.message);
});
}; };
prev.onclick = () => goToPage(state.pageNumber - 1);
next.onclick = () => goToPage(state.pageNumber + 1);
pageInput.onchange = () => goToPage(parseInt(pageInput.value, 10) || 1);
zoomOut.onclick = () => { zoomOut.onclick = () => {
state.zoom = Math.max(0.5, +(state.zoom / 1.25).toFixed(2)); state.zoom = Math.max(0.5, +(state.zoom / 1.25).toFixed(2));
renderPage().catch((e) => { zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%";
if (state.disposed) return; rerenderVisiblePages();
_clearPdfPreview(body);
fallbackFn("PDF 渲染失败:" + e.message);
});
}; };
zoomIn.onclick = () => { zoomIn.onclick = () => {
state.zoom = Math.min(3, +(state.zoom * 1.25).toFixed(2)); state.zoom = Math.min(3, +(state.zoom * 1.25).toFixed(2));
renderPage().catch((e) => { zoomLabel.textContent = state.zoom === 1 ? "适宽" : Math.round(state.zoom * 100) + "%";
if (state.disposed) return; rerenderVisiblePages();
_clearPdfPreview(body);
fallbackFn("PDF 渲染失败:" + e.message);
});
}; };
try { try {
@ -483,10 +507,58 @@ async function _showPdfIn(body, blob, fallbackFn) {
pageInput.disabled = false; pageInput.disabled = false;
zoomOut.disabled = false; zoomOut.disabled = false;
zoomIn.disabled = false; zoomIn.disabled = false;
await renderPage(); const firstPage = await pdf.getPage(1);
const firstViewport = firstPage.getViewport({ scale: 1 });
const initialScale = Math.max(0.1, (viewportHost.clientWidth - 24) / firstViewport.width);
viewportHost.innerHTML = '<div class="pdf-pages-list"></div>';
const pagesList = viewportHost.firstElementChild;
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
const shell = document.createElement("div");
shell.className = "pdf-page-shell";
shell.dataset.page = String(pageNumber);
shell.dataset.baseWidth = String(firstViewport.width);
shell.dataset.baseHeight = String(firstViewport.height);
shell.style.width = Math.floor(firstViewport.width * initialScale) + "px";
shell.style.height = Math.floor(firstViewport.height * initialScale) + "px";
shell.innerHTML = `<span>第 ${pageNumber} 页</span>`;
pagesList.appendChild(shell);
state.pageShells.push(shell);
}
state.pageObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) renderPage(Number(entry.target.dataset.page)).catch(handleRenderError);
}
}, { root: viewportHost, rootMargin: "100% 0px" });
for (const shell of state.pageShells) state.pageObserver.observe(shell);
viewportHost.addEventListener("scroll", () => {
if (state.scrollFrame) return;
state.scrollFrame = requestAnimationFrame(() => {
state.scrollFrame = null;
const center = viewportHost.scrollTop + viewportHost.clientHeight / 2;
let current = 1;
let distance = Infinity;
for (const shell of state.pageShells) {
const delta = Math.abs(shell.offsetTop + shell.offsetHeight / 2 - center);
if (delta < distance) {
distance = delta;
current = Number(shell.dataset.page);
}
const farAway = shell.offsetTop + shell.offsetHeight < viewportHost.scrollTop - viewportHost.clientHeight * 2
|| shell.offsetTop > viewportHost.scrollTop + viewportHost.clientHeight * 3;
if (farAway && shell.dataset.renderKey) {
shell.innerHTML = `<span>第 ${shell.dataset.page} 页</span>`;
shell.dataset.renderKey = "";
}
}
state.pageNumber = current;
pageInput.value = String(current);
});
}, { passive: true });
const rerender = () => { const rerender = () => {
if (state.disposed) return; if (state.disposed) return;
renderPage().catch(() => {}); rerenderVisiblePages();
}; };
if (window.ResizeObserver) { if (window.ResizeObserver) {
state.resizeObserver = new ResizeObserver(rerender); state.resizeObserver = new ResizeObserver(rerender);