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`;
}