feat(article): 正文图片可放大——PhotoZoom 极简 viewer 双轨挂载

正文 .prose-reading/.prose-tech 图片单击唤起全屏 dialog:fit-
contain+滚轮光标锚点缩放 1-5x+拖拽(钳制数学与图库 v2 逐行同
源)+双击 1x↔2.5x+Esc/关闭/背景点击关(contain 内容盒几何判
定);无 EXIF/caption/FLIP(纯图极简,按契约);document
委托点击软导航后仍生效。

车道:op-artalk(专项 9/9:SVG 架构图唤起/矢量 2.46x 不栅格化/
拖拽钳制/双击回正/Esc/jpg/背景关/软导航);收编验收四断言+E2E
34/34 全过

验证: 文章 SVG 点击唤起+滚轮放大(naturalWidth 960 矢量源)+Esc
      关闭实测;E2E 34/34

Spec: none (文章图放大,车道级交付)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-11-02 08:47:24 +08:00
1 parent a761cf8129
commit 3050b0b959
3 files changed
+169

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
---
// 正文图片缩放视图(文章页):单图极简——无画廊专属的 EXIF/caption/FLIP/chrome,
// 只有图 + 关闭钮。缩放引擎与 GalleryPage v2 同源:钳制数学(applyZoom clamp / 回 1 归零)
// 与 contain 几何判定(img 元素盒铺满 → 背景按内容盒算)逐行对齐,勿另起炉灶。
---
<dialog id="pz" class="pz" aria-label="图片查看">
<img id="pz-img" class="pz-img" alt="" />
<button id="pz-close" class="pz-close" type="button" aria-label="关闭(Esc)">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12"></path>
</svg>
</button>
</dialog>
<style>
.pz {
inset: 0;
width: 100%;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
overflow: hidden; /* 绝不出滚动条:完整可见交给 contain */
}
.pz::backdrop { background: rgba(8, 10, 14, 0.96); }
/* 绝对填满:grid/flex item 的 % 高有 auto 行高循环失效坑(GalleryPage v2 已踩) */
.pz-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
outline: none;
cursor: zoom-in;
touch-action: none;
will-change: transform;
}
.pz-img.zoomed { cursor: grab; }
.pz-img.grabbing { cursor: grabbing; }
.pz-close {
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(20, 24, 30, 0.55);
color: #dfe6f3;
cursor: pointer;
transition: border-color var(--dur) var(--ease);
}
.pz-close:hover { border-color: rgba(255, 255, 255, 0.5); }
/* 正文图给个可点暗示(仅光标,不动版式) */
:global(.prose-reading) :global(img),
:global(.prose-tech) :global(img) { cursor: zoom-in; }
</style>
<script>
// 单图缩放:与 GalleryPage v2 引擎同源(钳制/锚点/双击语义一致,数学勿分叉)
document.addEventListener('astro:page-load', () => {
const dlg = document.getElementById('pz') as HTMLDialogElement | null;
const img = document.getElementById('pz-img') as HTMLImageElement | null;
if (!dlg || !img) return;
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
const zoom = { s: 1, tx: 0, ty: 0 };
const applyZoom = () => {
// 平移钳制:缩放后图盒不得超出舞台(|t| ≤ 尺寸差/2)——与 GalleryPage 同源数学
const mx = Math.max(0, (img.clientWidth * (zoom.s - 1)) / 2);
const my = Math.max(0, (img.clientHeight * (zoom.s - 1)) / 2);
zoom.tx = Math.min(mx, Math.max(-mx, zoom.tx));
zoom.ty = Math.min(my, Math.max(-my, zoom.ty));
img.style.transform = zoom.s === 1 ? 'none' : `translate(${zoom.tx}px, ${zoom.ty}px) scale(${zoom.s})`;
img.classList.toggle('zoomed', zoom.s !== 1);
};
const resetZoom = () => { zoom.s = 1; zoom.tx = 0; zoom.ty = 0; applyZoom(); };
const zoomAt = (next: number, cx: number, cy: number) => {
const rect = img.getBoundingClientRect();
const ax = cx - rect.left - rect.width / 2 - zoom.tx;
const ay = cy - rect.top - rect.height / 2 - zoom.ty;
const k = next / zoom.s;
zoom.tx -= ax * (k - 1);
zoom.ty -= ay * (k - 1);
if (next <= 1) { zoom.s = 1; zoom.tx = 0; zoom.ty = 0; } else { zoom.s = next; }
applyZoom();
};
const containRect = () => {
const iw = img.naturalWidth || 1, ih = img.naturalHeight || 1;
const k = Math.min(innerWidth / iw, innerHeight / ih);
const w = iw * k, h = ih * k;
return { left: (innerWidth - w) / 2, top: (innerHeight - h) / 2, width: w, height: h };
};
// 正文图点击唤起(document 委托:软导航换页后依然生効)
document.addEventListener('click', (e) => {
const t = e.target as HTMLElement;
const source = t.closest('.prose-reading img, .prose-tech img') as HTMLImageElement | null;
if (!source) return;
if (dlg.open) return;
e.preventDefault();
img.src = source.currentSrc || source.src;
img.alt = source.alt || '';
resetZoom();
dlg.showModal();
});
document.getElementById('pz-close')?.addEventListener('click', () => dlg.close());
dlg.addEventListener('close', resetZoom);
dlg.addEventListener('wheel', (e) => {
if (!dlg.open) return;
e.preventDefault(); // 灯箱内滚轮一律缩放(含 Ctrl+滚轮)
const factor = Math.exp(-e.deltaY * 0.0015);
const next = Math.min(5, Math.max(1, zoom.s * factor));
if (next === zoom.s) return;
zoomAt(next, e.clientX, e.clientY);
}, { passive: false });
// 拖拽平移(仅放大态)
let drag: { x: number; y: number; tx: number; ty: number; moved: boolean } | null = null;
img.addEventListener('pointerdown', (e) => {
if (zoom.s <= 1) return;
e.preventDefault();
img.setPointerCapture(e.pointerId);
drag = { x: e.clientX, y: e.clientY, tx: zoom.tx, ty: zoom.ty, moved: false };
img.classList.add('grabbing');
});
img.addEventListener('pointermove', (e) => {
if (!drag) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
zoom.tx = drag.tx + dx;
zoom.ty = drag.ty + dy;
applyZoom();
});
const endDrag = () => { drag = null; img.classList.remove('grabbing'); };
img.addEventListener('pointerup', endDrag);
img.addEventListener('pointercancel', endDrag);
img.addEventListener('dblclick', (e) => {
if (zoom.s === 1) zoomAt(2.5, e.clientX, e.clientY);
else resetZoom();
});
// 背景点击关:contain 下 img 盒铺满视口,按内容盒几何判定(GalleryPage v2 结论)
dlg.addEventListener('click', (e) => {
if (!dlg.open) return;
if ((e.target as Element).closest('button')) return;
if (drag?.moved) return;
const c = containRect();
const inside =
e.clientX >= c.left - 8 && e.clientX <= c.left + c.width + 8 &&
e.clientY >= c.top - 8 && e.clientY <= c.top + c.height + 8;
if (!inside) dlg.close();
});
// reduced-motion:无过渡(本组件只用 transform 直接赋值,本无动画;此守卫留给未来)
void reduced;
});
</script>