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:
1 parent
a761cf8129
commit
3050b0b959
3 files changed
+169
No files matched your search
@@ -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>
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import PhotoZoom from '../../components/geek/PhotoZoom.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import ArtalkComments from '../../components/ArtalkComments.astro';
|
||||
import { trackOf } from '../../content.config';
|
||||
@@ -116,6 +117,7 @@ const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
|
||||
</nav>
|
||||
|
||||
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
|
||||
<PhotoZoom />
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// /en/archives/{slug}:英文版文章页——postsEn 优先;缺译回退 zh 正文 + 醒目标注
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import PhotoZoom from '../../../components/geek/PhotoZoom.astro';
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import ArtalkComments from '../../../components/ArtalkComments.astro';
|
||||
import AiTranslatedBadge from '../../../components/AiTranslatedBadge.astro';
|
||||
@@ -67,6 +68,7 @@ const minutes = Math.max(1, Math.ceil(words / 220));
|
||||
</nav>
|
||||
<ArtalkComments pageKey={`/archives/${entry.id}/`} pageTitle={entry.data.title} />
|
||||
</article>
|
||||
<PhotoZoom />
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in new issue
Block a user