Files
blog/src/components/geek/PhotoZoom.astro
T
wait4xandGLM-5.3 3050b0b959 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
2026-11-02 08:47:24 +08:00

166 lines
6.4 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
// 正文图片缩放视图(文章页):单图极简——无画廊专属的 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>