fix(web): 文件预览修滚动穿透 + 图片 Ctrl+滚轮缩放 + bump 0.12.10

- 滚动不穿透:主/小预览 .body 加 overscroll-behavior: contain,再挂一次性
  非 passive wheel 监听,容器不可滚或到边界时 preventDefault 断冒泡,背景
  对话列表不再被带滚。
- 图片缩放(仅图片):Ctrl+滚轮 ×1.1 步进(夹 0.1-8x),用 CSS zoom 而非
  transform(zoom 改布局盒,放大后 body 才出滚动条能看溢出);右下角 xx%
  比例徽标(挂 .card,滚动不跟走,1s 淡出);双击复位 100%;.body.center
  改 safe center 防 flex 居中裁掉溢出顶/左。
- wheel 监听只 init 挂一次到复用 body,缩放目标走 _zoomState WeakMap,免
  每次预览重复 addEventListener 泄漏。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-15 09:31:12 +08:00
parent 314a05e111
commit 31f46baaf6
4 changed files with 91 additions and 8 deletions

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-06-15(sandbox 装 emoji 字体修 mermaid 豆腐块) 最后更新:2026-06-15(文件预览修滚动穿透 + 图片 Ctrl+滚轮缩放)
--- ---
@ -21,6 +21,15 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-15 / 文件预览:修滚动穿透 + 图片 Ctrl+滚轮缩放
- 现象:web 端文件预览弹框内滚滚轮,事件冒泡到背景把对话列表也滚了(scroll chaining);且图片预览无缩放手段。
- 修法(纯前端,`web/static/js/preview.js` + `web/static/dev.html`):
- **滚动不穿透**:主/小预览 `.body``overscroll-behavior: contain`,再挂一次性非 passive `wheel` 监听 ── 容器不可滚(如图片正好铺满)或已到顶/底时 `preventDefault()` 断掉冒泡。
- **图片缩放**:仅图片(文本/md/docx/pdf 各有原生流/阅读器)。Ctrl+滚轮按 ×1.1 步进缩放(夹 0.18×),用 **CSS `zoom`** 而非 transform(zoom 改布局盒尺寸,放大后 body 才出滚动条能看溢出);右下角浮 `xx%` 比例徽标(挂 `.card` 上,滚动不跟走,1s 后淡出);双击复位 100%。`.body.center` 改 `safe center` 防 flex 居中把溢出顶/左裁掉够不到。
- wheel 监听只在 init 挂一次到复用的 body 元素,缩放目标走 `_zoomState` WeakMap,避免每次预览重复 addEventListener 泄漏。
- bump 0.12.9 → 0.12.10。
### 2026-06-15 / sandbox 装 emoji 字体:修 mermaid 图满图豆腐块 ### 2026-06-15 / sandbox 装 emoji 字体:修 mermaid 图满图豆腐块
- 现象:模型生成的 mermaid 架构图里几乎每个节点标签前缀的 emoji 图标(🌐🔥🛡 等)全渲染成空心方框 □。根因不在 mermaid 语法 / 布局 ── `deploy/sandbox/Dockerfile` 只装了 `fonts-noto-cjk` + `fonts-wqy-microhei`(中文不豆腐),**缺 emoji 字体**,chromium 渲染时找不到 emoji glyph 就用 tofu 占位。 - 现象:模型生成的 mermaid 架构图里几乎每个节点标签前缀的 emoji 图标(🌐🔥🛡 等)全渲染成空心方框 □。根因不在 mermaid 语法 / 布局 ── `deploy/sandbox/Dockerfile` 只装了 `fonts-noto-cjk` + `fonts-wqy-microhei`(中文不豆腐),**缺 emoji 字体**,chromium 渲染时找不到 emoji glyph 就用 tofu 占位。

View File

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

View File

@ -814,7 +814,7 @@
#file-preview-modal .card { #file-preview-modal .card {
width: 90vw; height: 90vh; max-width: 1200px; width: 90vw; height: 90vh; max-width: 1200px;
max-height: calc(100vh - var(--preview-bottom-inset, 0px) - 32px); max-height: calc(100vh - var(--preview-bottom-inset, 0px) - 32px);
display: flex; flex-direction: column; display: flex; flex-direction: column; position: relative;
box-shadow: 0 12px 32px rgba(0,0,0,.22); box-shadow: 0 12px 32px rgba(0,0,0,.22);
} }
#file-preview-modal .hdr { #file-preview-modal .hdr {
@ -825,8 +825,17 @@
flex: 1; font-weight: 500; overflow: hidden; flex: 1; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; text-overflow: ellipsis; white-space: nowrap;
} }
#file-preview-modal .body { flex: 1; overflow: auto; padding: 12px; position: relative; } #file-preview-modal .body { flex: 1; overflow: auto; padding: 12px; position: relative; overscroll-behavior: contain; }
#file-preview-modal .body.center { display: flex; align-items: center; justify-content: center; } /* 预览缩放比例徽标(挂在 .card 上,放大滚动时不跟随内容滚走) */
.zoom-badge {
position: absolute; right: 14px; bottom: 12px; z-index: 3;
background: rgba(0,0,0,0.62); color: #fff;
font-size: 12px; font-weight: 500; padding: 3px 9px;
border-radius: var(--r-md); pointer-events: none;
opacity: 0; transition: opacity .25s ease;
}
.zoom-badge.show { opacity: 1; }
#file-preview-modal .body.center { display: flex; align-items: safe center; justify-content: safe center; }
#file-preview-modal .body .ph { color: var(--muted); font-size: 13px; text-align: center; } #file-preview-modal .body .ph { color: var(--muted); font-size: 13px; text-align: center; }
.preview-spinner { .preview-spinner {
width: 22px; height: 22px; border-radius: 50%; margin: 0 auto 10px; width: 22px; height: 22px; border-radius: 50%; margin: 0 auto 10px;
@ -864,7 +873,7 @@
#mini-preview-modal { background: rgba(0,0,0,0.18); z-index: 96; align-items: flex-start; justify-content: flex-end; padding: 56px 18px 0 0; } #mini-preview-modal { background: rgba(0,0,0,0.18); z-index: 96; align-items: flex-start; justify-content: flex-end; padding: 56px 18px 0 0; }
#mini-preview-modal .card { #mini-preview-modal .card {
width: min(520px, 92vw); height: min(420px, 72vh); width: min(520px, 92vw); height: min(420px, 72vh);
display: flex; flex-direction: column; display: flex; flex-direction: column; position: relative;
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
} }
#mini-preview-modal .hdr { #mini-preview-modal .hdr {
@ -875,8 +884,8 @@
flex: 1; font-weight: 500; overflow: hidden; flex: 1; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; text-overflow: ellipsis; white-space: nowrap;
} }
#mini-preview-modal .body { flex: 1; overflow: auto; padding: 10px; } #mini-preview-modal .body { flex: 1; overflow: auto; padding: 10px; overscroll-behavior: contain; }
#mini-preview-modal .body.center { display: flex; align-items: center; justify-content: center; } #mini-preview-modal .body.center { display: flex; align-items: safe center; justify-content: safe center; }
#mini-preview-modal .body .ph { color: var(--muted); font-size: 12px; text-align: center; } #mini-preview-modal .body .ph { color: var(--muted); font-size: 12px; text-align: center; }
#mini-preview-modal .body img.preview-img, #mini-preview-modal .body img.preview-img,
#mini-preview-modal .body video.preview-video { #mini-preview-modal .body video.preview-video {

View File

@ -62,12 +62,69 @@ export function _categorize(rel) {
let _fpCurrentRel = null; let _fpCurrentRel = null;
// ───── 滚动不穿透 + 图片 Ctrl+滚轮缩放 ─────
// body 元素在多次预览间复用,故 wheel 监听只在 init 时挂一次(_bindBodyWheel),
// 缩放目标用 _zoomState 记录,避免每次预览重复 addEventListener 泄漏。
const _zoomState = new WeakMap(); // bodyEl -> { img, scale, badge, timer }
function _applyZoom(z) {
// 用 CSS zoom(非 transform):zoom 改变布局盒尺寸,放大后 body 才会出滚动条能看溢出部分
z.img.style.zoom = z.scale === 1 ? "" : z.scale;
z.img.style.cursor = z.scale === 1 ? "zoom-in" : "zoom-out";
z.badge.textContent = Math.round(z.scale * 100) + "%";
z.badge.classList.add("show");
if (z.timer) clearTimeout(z.timer);
z.timer = setTimeout(() => z.badge.classList.remove("show"), 1000);
}
// 给某 body 内的图片接上 Ctrl+滚轮缩放(badge + 双击复位),记录到 _zoomState。
function _makeImageZoomable(bodyEl, img) {
_clearZoom(bodyEl);
const card = bodyEl.closest(".card") || bodyEl;
const badge = document.createElement("div");
badge.className = "zoom-badge"; // 挂到 card 而非 body,放大后 body 滚动时徽标不跟着滚走
card.appendChild(badge);
const z = { img, scale: 1, badge, timer: null };
_zoomState.set(bodyEl, z);
img.style.cursor = "zoom-in";
img.addEventListener("dblclick", () => { z.scale = 1; _applyZoom(z); });
}
function _clearZoom(bodyEl) {
const z = _zoomState.get(bodyEl);
if (!z) return;
if (z.timer) clearTimeout(z.timer);
z.badge.remove();
_zoomState.delete(bodyEl);
}
// 只挂一次:Ctrl+滚轮缩当前图片;否则拦住滚动穿透到背景对话列表。
function _bindBodyWheel(bodyEl) {
bodyEl.addEventListener("wheel", (e) => {
const z = _zoomState.get(bodyEl);
if (e.ctrlKey) {
if (!z) return; // 无可缩放图片:不拦,交还浏览器
e.preventDefault(); // 有图片:吃掉浏览器页面缩放,改缩图片
z.scale = Math.min(8, Math.max(0.1, +(z.scale * (e.deltaY < 0 ? 1.1 : 1 / 1.1)).toFixed(3)));
_applyZoom(z);
return;
}
// 非 ctrl:容器不可滚 / 已到边界时阻断,避免冒泡到背景滚动对话列表
const canScroll = bodyEl.scrollHeight > bodyEl.clientHeight + 1;
if (!canScroll) { e.preventDefault(); return; }
const atTop = bodyEl.scrollTop <= 0 && e.deltaY < 0;
const atBottom = bodyEl.scrollTop + bodyEl.clientHeight >= bodyEl.scrollHeight - 1 && e.deltaY > 0;
if (atTop || atBottom) e.preventDefault();
}, { passive: false });
}
export async function openFilePreview(rel) { export async function openFilePreview(rel) {
_fpCurrentRel = rel; _fpCurrentRel = rel;
const name = rel.split("/").pop() || rel; const name = rel.split("/").pop() || rel;
$("fp-name").textContent = name; $("fp-name").textContent = name;
$("fp-meta").textContent = ""; $("fp-meta").textContent = "";
const body = $("fp-body"); const body = $("fp-body");
_clearZoom(body);
body.className = "body center"; body.className = "body center";
body.innerHTML = `<div class="ph">加载中…</div>`; body.innerHTML = `<div class="ph">加载中…</div>`;
// 让出聊天输入区高度,弹框不遮挡 chat-form(无活动任务时 cf 隐藏,inset = 0) // 让出聊天输入区高度,弹框不遮挡 chat-form(无活动任务时 cf 隐藏,inset = 0)
@ -122,6 +179,7 @@ function _showImage(blob) {
img.className = "preview-img"; img.className = "preview-img";
img.src = url; img.src = url;
body.appendChild(img); body.appendChild(img);
_makeImageZoomable(body, img);
} }
function _showVideo(blob) { function _showVideo(blob) {
@ -281,6 +339,7 @@ function _showFallback(msg) {
export function closeFilePreview() { export function closeFilePreview() {
$("file-preview-modal").classList.remove("show"); $("file-preview-modal").classList.remove("show");
$("file-preview-modal").style.removeProperty("--preview-bottom-inset"); $("file-preview-modal").style.removeProperty("--preview-bottom-inset");
_clearZoom($("fp-body"));
$("fp-body").innerHTML = ""; $("fp-body").innerHTML = "";
_flushBlobUrls(); _flushBlobUrls();
_fpCurrentRel = null; _fpCurrentRel = null;
@ -308,6 +367,7 @@ async function openMiniFilePreview(rel) {
$("mp-name").textContent = name; $("mp-name").textContent = name;
$("mp-meta").textContent = ""; $("mp-meta").textContent = "";
const body = $("mp-body"); const body = $("mp-body");
_clearZoom(body);
body.className = "body center"; body.className = "body center";
body.innerHTML = `<div class="ph">加载中…</div>`; body.innerHTML = `<div class="ph">加载中…</div>`;
_flushMiniBlobUrls(); _flushMiniBlobUrls();
@ -355,6 +415,7 @@ async function openMiniFilePreview(rel) {
img.className = "preview-img"; img.className = "preview-img";
img.src = _trackMiniBlobUrl(blob); img.src = _trackMiniBlobUrl(blob);
body.appendChild(img); body.appendChild(img);
_makeImageZoomable(body, img);
} else if (cat === "video") { } else if (cat === "video") {
body.className = "body center"; body.className = "body center";
const v = document.createElement("video"); const v = document.createElement("video");
@ -391,6 +452,7 @@ function _showMiniFallback(msg) {
} }
export function closeMiniPreview() { export function closeMiniPreview() {
$("mini-preview-modal").classList.remove("show"); $("mini-preview-modal").classList.remove("show");
_clearZoom($("mp-body"));
$("mp-body").innerHTML = ""; $("mp-body").innerHTML = "";
_flushMiniBlobUrls(); _flushMiniBlobUrls();
_mpCurrentRel = null; _mpCurrentRel = null;
@ -402,6 +464,9 @@ export function closePreviewIfShowing(rel) {
if (_mpCurrentRel === rel) closeMiniPreview(); if (_mpCurrentRel === rel) closeMiniPreview();
} }
_bindBodyWheel($("fp-body"));
_bindBodyWheel($("mp-body"));
$("fp-close").onclick = closeFilePreview; $("fp-close").onclick = closeFilePreview;
$("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); }; $("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); };
$("file-preview-modal").addEventListener("click", (e) => { $("file-preview-modal").addEventListener("click", (e) => {