From 31f46baaf6ec3cf8f0d09dec349b2957a6286066 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 15 Jun 2026 09:31:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=96=87=E4=BB=B6=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E4=BF=AE=E6=BB=9A=E5=8A=A8=E7=A9=BF=E9=80=8F=20+=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87=20Ctrl+=E6=BB=9A=E8=BD=AE=E7=BC=A9=E6=94=BE?= =?UTF-8?q?=20+=20bump=200.12.10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 滚动不穿透:主/小预览 .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) --- PROGRESS.md | 11 ++++++- core/__init__.py | 2 +- web/static/dev.html | 21 +++++++++---- web/static/js/preview.js | 65 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 700e9b8..22acc24 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `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.1–8×),用 **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 图满图豆腐块 - 现象:模型生成的 mermaid 架构图里几乎每个节点标签前缀的 emoji 图标(🌐🔥🛡 等)全渲染成空心方框 □。根因不在 mermaid 语法 / 布局 ── `deploy/sandbox/Dockerfile` 只装了 `fonts-noto-cjk` + `fonts-wqy-microhei`(中文不豆腐),**缺 emoji 字体**,chromium 渲染时找不到 emoji glyph 就用 tofu 占位。 diff --git a/core/__init__.py b/core/__init__.py index f1d29e7..3ee770e 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.12.9" +__version__ = "0.12.10" diff --git a/web/static/dev.html b/web/static/dev.html index 3638006..cff4434 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -814,7 +814,7 @@ #file-preview-modal .card { width: 90vw; height: 90vh; max-width: 1200px; 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); } #file-preview-modal .hdr { @@ -825,8 +825,17 @@ flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - #file-preview-modal .body { flex: 1; overflow: auto; padding: 12px; position: relative; } - #file-preview-modal .body.center { display: flex; align-items: center; justify-content: center; } + #file-preview-modal .body { flex: 1; overflow: auto; padding: 12px; position: relative; overscroll-behavior: contain; } + /* 预览缩放比例徽标(挂在 .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; } .preview-spinner { 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 .card { 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); } #mini-preview-modal .hdr { @@ -875,8 +884,8 @@ flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - #mini-preview-modal .body { flex: 1; overflow: auto; padding: 10px; } - #mini-preview-modal .body.center { display: flex; align-items: center; justify-content: center; } + #mini-preview-modal .body { flex: 1; overflow: auto; padding: 10px; overscroll-behavior: contain; } + #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 img.preview-img, #mini-preview-modal .body video.preview-video { diff --git a/web/static/js/preview.js b/web/static/js/preview.js index d060ac4..3248373 100644 --- a/web/static/js/preview.js +++ b/web/static/js/preview.js @@ -62,12 +62,69 @@ export function _categorize(rel) { 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) { _fpCurrentRel = rel; const name = rel.split("/").pop() || rel; $("fp-name").textContent = name; $("fp-meta").textContent = ""; const body = $("fp-body"); + _clearZoom(body); body.className = "body center"; body.innerHTML = `
加载中…
`; // 让出聊天输入区高度,弹框不遮挡 chat-form(无活动任务时 cf 隐藏,inset = 0) @@ -122,6 +179,7 @@ function _showImage(blob) { img.className = "preview-img"; img.src = url; body.appendChild(img); + _makeImageZoomable(body, img); } function _showVideo(blob) { @@ -281,6 +339,7 @@ function _showFallback(msg) { export function closeFilePreview() { $("file-preview-modal").classList.remove("show"); $("file-preview-modal").style.removeProperty("--preview-bottom-inset"); + _clearZoom($("fp-body")); $("fp-body").innerHTML = ""; _flushBlobUrls(); _fpCurrentRel = null; @@ -308,6 +367,7 @@ async function openMiniFilePreview(rel) { $("mp-name").textContent = name; $("mp-meta").textContent = ""; const body = $("mp-body"); + _clearZoom(body); body.className = "body center"; body.innerHTML = `
加载中…
`; _flushMiniBlobUrls(); @@ -355,6 +415,7 @@ async function openMiniFilePreview(rel) { img.className = "preview-img"; img.src = _trackMiniBlobUrl(blob); body.appendChild(img); + _makeImageZoomable(body, img); } else if (cat === "video") { body.className = "body center"; const v = document.createElement("video"); @@ -391,6 +452,7 @@ function _showMiniFallback(msg) { } export function closeMiniPreview() { $("mini-preview-modal").classList.remove("show"); + _clearZoom($("mp-body")); $("mp-body").innerHTML = ""; _flushMiniBlobUrls(); _mpCurrentRel = null; @@ -402,6 +464,9 @@ export function closePreviewIfShowing(rel) { if (_mpCurrentRel === rel) closeMiniPreview(); } +_bindBodyWheel($("fp-body")); +_bindBodyWheel($("mp-body")); + $("fp-close").onclick = closeFilePreview; $("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); }; $("file-preview-modal").addEventListener("click", (e) => {