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