| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- import {
- ChangeDetectionStrategy,
- Component,
- ElementRef,
- effect,
- input,
- output,
- viewChild,
- } from '@angular/core';
- import { downloadAs } from '../../../core/attachment-fetch';
- /**
- * 图片灯箱 —— 附件图片的**唯一**放大查看方式。
- *
- * 依据《预览面板重构-设计-20261030》第二节:图片不进右侧预览,点击消息里的图片
- * 直接打开本灯箱。
- *
- * ## 为什么不用 ui-image 内建灯箱
- * 底座 `ui-image` 的灯箱服务于任意图片位(含左右切图),没有文件名/大小/下载;
- * 改它会波及所有使用 `ui-image` 的页面。本组件只做附件预览这一件事,边界清楚
- * (rules/18 §8「同一功能只做一种方案」——两者场景不同,不是同一方案)。
- *
- * ## 交互
- * - Esc / 点击遮罩 / ✕ 关闭;打开时焦点落到关闭按钮
- * - 下载走 blob(跨域 `download` 属性会被浏览器忽略)
- * - 不做缩放/切图:保持单一职责,避免与正文图片的 gallery 语义混淆
- */
- @Component({
- selector: 'app-image-lightbox',
- changeDetection: ChangeDetectionStrategy.OnPush,
- template: `
- @if (open()) {
- <!--
- 关闭方式(2026-10-31 修复):
- - 点击图片以外的区域关闭:命中 mask 本身才关(frame 上 stopPropagation)
- - Esc 关闭:原来用模板 keydown,依赖焦点恰好在遮罩内,点过图片/工具条后
- 焦点丢失就失效(用户实测「点一下后 Esc 关不掉」)。改为全局 window 监听。
- -->
- <div #mask class="lb-mask" tabindex="-1" (click)="onMaskClick($event)">
- <div
- class="lb-frame"
- role="dialog"
- aria-modal="true"
- [attr.aria-label]="'预览图片 ' + (name() || '')"
- >
- <img #img class="lb-img" [src]="url()" [alt]="name() || '图片'" />
- <footer class="lb-bar">
- <span class="lb-meta">
- <span class="lb-name ellipsis" [title]="name()">{{ name() || '图片' }}</span>
- @if (sizeLabel()) {
- <span class="lb-dot" aria-hidden="true">·</span>
- <span>{{ sizeLabel() }}</span>
- }
- </span>
- <span class="lb-actions">
- <button type="button" class="lb-act" (click)="download()">
- <i class="fa-solid fa-download" aria-hidden="true"></i>
- <span>下载</span>
- </button>
- <a class="lb-act" [href]="url()" target="_blank" rel="noopener noreferrer">
- <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
- <span>新标签打开</span>
- </a>
- <button
- #closeBtn
- type="button"
- class="lb-act lb-act--icon"
- aria-label="关闭预览"
- (click)="close()"
- >
- <i class="fa-solid fa-xmark" aria-hidden="true"></i>
- </button>
- </span>
- </footer>
- </div>
- </div>
- }
- `,
- styles: [
- `
- :host {
- display: contents;
- }
- /* 遮罩只是焦点落点(Esc 已由全局监听处理),不显示聚焦框 */
- .lb-mask:focus:not(:focus-visible) {
- box-shadow: none;
- }
- .lb-mask {
- position: fixed;
- inset: 0;
- z-index: var(--z-preview);
- display: grid;
- place-items: center;
- padding: var(--sp-6);
- /* 深色灯箱:用令牌 --overlay 叠加两档得到 ~90% 的深色遮罩,
- 不写死 rgba(上一步三档叠成近似纯黑,背景完全不可见,已收敛为两档) */
- background: var(--overlay);
- box-shadow: inset 0 0 0 100vmax var(--overlay);
- animation: lb-in var(--motion-detail) var(--ease-standard);
- }
- /* 工具条固定在视口底部,图片单独约束在剩余空间内(原实现文字被裁) */
- .lb-frame {
- position: fixed;
- inset: 0;
- display: flex;
- padding: var(--sp-4);
- flex-direction: column;
- align-items: center;
- justify-content: center;
- }
- .lb-img {
- display: block;
- max-width: min(92vw, 1400px);
- max-height: calc(100dvh - 140px);
- object-fit: contain;
- border-radius: var(--r-md);
- box-shadow: var(--shadow-pop);
- }
- .lb-bar {
- display: flex;
- width: 100%;
- max-width: 92vw;
- min-height: var(--touch-min);
- align-items: center;
- justify-content: space-between;
- gap: var(--sp-4);
- padding: var(--sp-2) var(--sp-3);
- color: var(--inverse);
- font-size: var(--fs-13);
- }
- .lb-meta {
- display: flex;
- min-width: 0;
- align-items: center;
- gap: var(--sp-2);
- }
- .lb-name {
- max-width: 46vw;
- }
- .lb-dot {
- opacity: 0.5;
- }
- .lb-actions {
- display: flex;
- flex-shrink: 0;
- align-items: center;
- gap: var(--sp-1);
- }
- .lb-act {
- display: inline-flex;
- min-height: 36px;
- align-items: center;
- gap: var(--sp-2);
- padding: 0 var(--sp-3);
- border: 0;
- border-radius: var(--r-sm);
- background: color-mix(in srgb, var(--inverse) 12%, transparent);
- color: inherit;
- font: inherit;
- text-decoration: none;
- cursor: pointer;
- transition: background var(--motion-fast) var(--ease-standard);
- }
- .lb-act:hover {
- background: color-mix(in srgb, var(--inverse) 22%, transparent);
- }
- .lb-act:focus-visible {
- outline: 2px solid var(--inverse);
- outline-offset: 2px;
- }
- .lb-act--icon {
- width: 36px;
- justify-content: center;
- padding: 0;
- }
- .ellipsis {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- @keyframes lb-in {
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
- }
- @media (prefers-reduced-motion: reduce) {
- .lb-mask {
- animation: none;
- }
- }
- `,
- ],
- })
- export class ImageLightboxComponent {
- readonly open = input(false);
- readonly url = input('');
- readonly name = input('');
- /** 字节数(0 / 缺省时不显示) */
- readonly size = input(0);
- readonly closed = output<void>();
- private readonly closeBtn = viewChild<ElementRef<HTMLButtonElement>>('closeBtn');
- readonly sizeLabel = (): string => (this.size() > 0 ? fmtSize(this.size()) : '');
- /** 关闭按钮索引(ElementRef 取用):让打开时焦点落在一个可聚焦元素上 */
- private readonly maskEl = viewChild<ElementRef<HTMLElement>>('mask');
- private readonly imgEl = viewChild<ElementRef<HTMLImageElement>>('img');
- /** 全局 Esc:不依赖焦点位置 */
- private readonly onKey = (e: KeyboardEvent): void => {
- if (e.key === 'Escape' && this.open()) {
- e.stopPropagation();
- this.close();
- }
- };
- constructor() {
- effect((onCleanup) => {
- if (!this.open()) return;
- queueMicrotask(() => {
- (this.maskEl()?.nativeElement ?? this.closeBtn()?.nativeElement)?.focus();
- });
- window.addEventListener('keydown', this.onKey, true);
- onCleanup(() => window.removeEventListener('keydown', this.onKey, true));
- });
- }
- /**
- * 点“图片以外的区域”关闭(2026-10-31)。
- *
- * 🔴 原来用 e.target === currentTarget:图片带 box-shadow 时命中判定会落到
- * 包围盒外的阴影上,点击被当成“点到了 frame 之外但仍在 mask 内”,无法关闭(实测)。
- * 改为按坐标判断:只有落在图片本体或底部工具条矩形之外才关闭。
- */
- onMaskClick(e: MouseEvent): void {
- const img = this.imgEl()?.nativeElement;
- const bar = this.closeBtn()?.nativeElement.closest('.lb-bar') as HTMLElement | null;
- const inRect = (el: HTMLElement | null | undefined): boolean => {
- if (!el) return false;
- const r = el.getBoundingClientRect();
- return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
- };
- if (inRect(img) || inRect(bar)) return;
- this.close();
- }
- async download(): Promise<void> {
- await downloadAs(this.url(), this.name() || 'image');
- }
- close(): void {
- this.closed.emit();
- }
- }
- /** 字节数 → 人类可读(与聊天页 fmtSize 口径一致) */
- function fmtSize(bytes: number): string {
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
- }
|