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:
parent
314a05e111
commit
31f46baaf6
11
PROGRESS.md
11
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 占位。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.12.9"
|
||||
__version__ = "0.12.10"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = `<div class="ph">加载中…</div>`;
|
||||
// 让出聊天输入区高度,弹框不遮挡 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 = `<div class="ph">加载中…</div>`;
|
||||
_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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue