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()) {
} `, 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(); private readonly closeBtn = viewChild>('closeBtn'); readonly sizeLabel = (): string => (this.size() > 0 ? fmtSize(this.size()) : ''); /** 关闭按钮索引(ElementRef 取用):让打开时焦点落在一个可聚焦元素上 */ private readonly maskEl = viewChild>('mask'); private readonly imgEl = viewChild>('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 { 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`; }