image-lightbox.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import {
  2. ChangeDetectionStrategy,
  3. Component,
  4. ElementRef,
  5. effect,
  6. input,
  7. output,
  8. viewChild,
  9. } from '@angular/core';
  10. import { downloadAs } from '../../../core/attachment-fetch';
  11. /**
  12. * 图片灯箱 —— 附件图片的**唯一**放大查看方式。
  13. *
  14. * 依据《预览面板重构-设计-20261030》第二节:图片不进右侧预览,点击消息里的图片
  15. * 直接打开本灯箱。
  16. *
  17. * ## 为什么不用 ui-image 内建灯箱
  18. * 底座 `ui-image` 的灯箱服务于任意图片位(含左右切图),没有文件名/大小/下载;
  19. * 改它会波及所有使用 `ui-image` 的页面。本组件只做附件预览这一件事,边界清楚
  20. * (rules/18 §8「同一功能只做一种方案」——两者场景不同,不是同一方案)。
  21. *
  22. * ## 交互
  23. * - Esc / 点击遮罩 / ✕ 关闭;打开时焦点落到关闭按钮
  24. * - 下载走 blob(跨域 `download` 属性会被浏览器忽略)
  25. * - 不做缩放/切图:保持单一职责,避免与正文图片的 gallery 语义混淆
  26. */
  27. @Component({
  28. selector: 'app-image-lightbox',
  29. changeDetection: ChangeDetectionStrategy.OnPush,
  30. template: `
  31. @if (open()) {
  32. <!--
  33. 关闭方式(2026-10-31 修复):
  34. - 点击图片以外的区域关闭:命中 mask 本身才关(frame 上 stopPropagation)
  35. - Esc 关闭:原来用模板 keydown,依赖焦点恰好在遮罩内,点过图片/工具条后
  36. 焦点丢失就失效(用户实测「点一下后 Esc 关不掉」)。改为全局 window 监听。
  37. -->
  38. <div #mask class="lb-mask" tabindex="-1" (click)="onMaskClick($event)">
  39. <div
  40. class="lb-frame"
  41. role="dialog"
  42. aria-modal="true"
  43. [attr.aria-label]="'预览图片 ' + (name() || '')"
  44. >
  45. <img #img class="lb-img" [src]="url()" [alt]="name() || '图片'" />
  46. <footer class="lb-bar">
  47. <span class="lb-meta">
  48. <span class="lb-name ellipsis" [title]="name()">{{ name() || '图片' }}</span>
  49. @if (sizeLabel()) {
  50. <span class="lb-dot" aria-hidden="true">·</span>
  51. <span>{{ sizeLabel() }}</span>
  52. }
  53. </span>
  54. <span class="lb-actions">
  55. <button type="button" class="lb-act" (click)="download()">
  56. <i class="fa-solid fa-download" aria-hidden="true"></i>
  57. <span>下载</span>
  58. </button>
  59. <a class="lb-act" [href]="url()" target="_blank" rel="noopener noreferrer">
  60. <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
  61. <span>新标签打开</span>
  62. </a>
  63. <button
  64. #closeBtn
  65. type="button"
  66. class="lb-act lb-act--icon"
  67. aria-label="关闭预览"
  68. (click)="close()"
  69. >
  70. <i class="fa-solid fa-xmark" aria-hidden="true"></i>
  71. </button>
  72. </span>
  73. </footer>
  74. </div>
  75. </div>
  76. }
  77. `,
  78. styles: [
  79. `
  80. :host {
  81. display: contents;
  82. }
  83. /* 遮罩只是焦点落点(Esc 已由全局监听处理),不显示聚焦框 */
  84. .lb-mask:focus:not(:focus-visible) {
  85. box-shadow: none;
  86. }
  87. .lb-mask {
  88. position: fixed;
  89. inset: 0;
  90. z-index: var(--z-preview);
  91. display: grid;
  92. place-items: center;
  93. padding: var(--sp-6);
  94. /* 深色灯箱:用令牌 --overlay 叠加两档得到 ~90% 的深色遮罩,
  95. 不写死 rgba(上一步三档叠成近似纯黑,背景完全不可见,已收敛为两档) */
  96. background: var(--overlay);
  97. box-shadow: inset 0 0 0 100vmax var(--overlay);
  98. animation: lb-in var(--motion-detail) var(--ease-standard);
  99. }
  100. /* 工具条固定在视口底部,图片单独约束在剩余空间内(原实现文字被裁) */
  101. .lb-frame {
  102. position: fixed;
  103. inset: 0;
  104. display: flex;
  105. padding: var(--sp-4);
  106. flex-direction: column;
  107. align-items: center;
  108. justify-content: center;
  109. }
  110. .lb-img {
  111. display: block;
  112. max-width: min(92vw, 1400px);
  113. max-height: calc(100dvh - 140px);
  114. object-fit: contain;
  115. border-radius: var(--r-md);
  116. box-shadow: var(--shadow-pop);
  117. }
  118. .lb-bar {
  119. display: flex;
  120. width: 100%;
  121. max-width: 92vw;
  122. min-height: var(--touch-min);
  123. align-items: center;
  124. justify-content: space-between;
  125. gap: var(--sp-4);
  126. padding: var(--sp-2) var(--sp-3);
  127. color: var(--inverse);
  128. font-size: var(--fs-13);
  129. }
  130. .lb-meta {
  131. display: flex;
  132. min-width: 0;
  133. align-items: center;
  134. gap: var(--sp-2);
  135. }
  136. .lb-name {
  137. max-width: 46vw;
  138. }
  139. .lb-dot {
  140. opacity: 0.5;
  141. }
  142. .lb-actions {
  143. display: flex;
  144. flex-shrink: 0;
  145. align-items: center;
  146. gap: var(--sp-1);
  147. }
  148. .lb-act {
  149. display: inline-flex;
  150. min-height: 36px;
  151. align-items: center;
  152. gap: var(--sp-2);
  153. padding: 0 var(--sp-3);
  154. border: 0;
  155. border-radius: var(--r-sm);
  156. background: color-mix(in srgb, var(--inverse) 12%, transparent);
  157. color: inherit;
  158. font: inherit;
  159. text-decoration: none;
  160. cursor: pointer;
  161. transition: background var(--motion-fast) var(--ease-standard);
  162. }
  163. .lb-act:hover {
  164. background: color-mix(in srgb, var(--inverse) 22%, transparent);
  165. }
  166. .lb-act:focus-visible {
  167. outline: 2px solid var(--inverse);
  168. outline-offset: 2px;
  169. }
  170. .lb-act--icon {
  171. width: 36px;
  172. justify-content: center;
  173. padding: 0;
  174. }
  175. .ellipsis {
  176. overflow: hidden;
  177. text-overflow: ellipsis;
  178. white-space: nowrap;
  179. }
  180. @keyframes lb-in {
  181. from {
  182. opacity: 0;
  183. }
  184. to {
  185. opacity: 1;
  186. }
  187. }
  188. @media (prefers-reduced-motion: reduce) {
  189. .lb-mask {
  190. animation: none;
  191. }
  192. }
  193. `,
  194. ],
  195. })
  196. export class ImageLightboxComponent {
  197. readonly open = input(false);
  198. readonly url = input('');
  199. readonly name = input('');
  200. /** 字节数(0 / 缺省时不显示) */
  201. readonly size = input(0);
  202. readonly closed = output<void>();
  203. private readonly closeBtn = viewChild<ElementRef<HTMLButtonElement>>('closeBtn');
  204. readonly sizeLabel = (): string => (this.size() > 0 ? fmtSize(this.size()) : '');
  205. /** 关闭按钮索引(ElementRef 取用):让打开时焦点落在一个可聚焦元素上 */
  206. private readonly maskEl = viewChild<ElementRef<HTMLElement>>('mask');
  207. private readonly imgEl = viewChild<ElementRef<HTMLImageElement>>('img');
  208. /** 全局 Esc:不依赖焦点位置 */
  209. private readonly onKey = (e: KeyboardEvent): void => {
  210. if (e.key === 'Escape' && this.open()) {
  211. e.stopPropagation();
  212. this.close();
  213. }
  214. };
  215. constructor() {
  216. effect((onCleanup) => {
  217. if (!this.open()) return;
  218. queueMicrotask(() => {
  219. (this.maskEl()?.nativeElement ?? this.closeBtn()?.nativeElement)?.focus();
  220. });
  221. window.addEventListener('keydown', this.onKey, true);
  222. onCleanup(() => window.removeEventListener('keydown', this.onKey, true));
  223. });
  224. }
  225. /**
  226. * 点“图片以外的区域”关闭(2026-10-31)。
  227. *
  228. * 🔴 原来用 e.target === currentTarget:图片带 box-shadow 时命中判定会落到
  229. * 包围盒外的阴影上,点击被当成“点到了 frame 之外但仍在 mask 内”,无法关闭(实测)。
  230. * 改为按坐标判断:只有落在图片本体或底部工具条矩形之外才关闭。
  231. */
  232. onMaskClick(e: MouseEvent): void {
  233. const img = this.imgEl()?.nativeElement;
  234. const bar = this.closeBtn()?.nativeElement.closest('.lb-bar') as HTMLElement | null;
  235. const inRect = (el: HTMLElement | null | undefined): boolean => {
  236. if (!el) return false;
  237. const r = el.getBoundingClientRect();
  238. return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
  239. };
  240. if (inRect(img) || inRect(bar)) return;
  241. this.close();
  242. }
  243. async download(): Promise<void> {
  244. await downloadAs(this.url(), this.name() || 'image');
  245. }
  246. close(): void {
  247. this.closed.emit();
  248. }
  249. }
  250. /** 字节数 → 人类可读(与聊天页 fmtSize 口径一致) */
  251. function fmtSize(bytes: number): string {
  252. if (bytes < 1024) return `${bytes} B`;
  253. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  254. return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
  255. }