composer.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. import {
  2. ChangeDetectionStrategy,
  3. Component,
  4. booleanAttribute,
  5. ElementRef,
  6. computed,
  7. effect,
  8. input,
  9. output,
  10. signal,
  11. viewChild,
  12. } from '@angular/core';
  13. import { UiProgress, UiSpinner } from '../../../ui';
  14. /**
  15. * 消息输入栏 —— 自建(《组件规范》§四-2)
  16. *
  17. * 覆盖 SPEC FR-04:
  18. * AC-04.5 Enter 发送 / Shift+Enter 换行 / **真实自增高**(`scrollHeight` 驱动)
  19. * AC-04.3 附件入口 + 上传进度可见
  20. * AC-04.8 发送中禁用,不产生并发
  21. *
  22. * 🔴 「真实自增高」= 每次输入后测量 `scrollHeight` 并同步 `height`,
  23. * 而不是只设 `rows` 或 `field-sizing`(浏览器支持不一致)。
  24. * 高度上限由 max-height 控制,超出后内部滚动。
  25. */
  26. @Component({
  27. selector: 'app-composer',
  28. changeDetection: ChangeDetectionStrategy.OnPush,
  29. imports: [UiSpinner, UiProgress],
  30. template: `
  31. <!--
  32. 粘贴 / 拖拽上传(2026-10-30):
  33. · 在输入框里 Ctrl+V 粘贴图片(截图工具直接粘)→ 自动加入待发送附件
  34. · 把文件拖到输入区上 → 松手即加入;拖入时整块区高亮提示可放
  35. 两者都走与「选择文件」相同的 attach 输出,后续链路完全一致。
  36. -->
  37. <form
  38. class="composer"
  39. [class.kb-focus]="keyboardFocus()"
  40. [class.is-busy]="sending()"
  41. [class.is-dragover]="dragging()"
  42. (submit)="onSubmit($event)"
  43. (dragover)="onDragOver($event)"
  44. (dragleave)="onDragLeave($event)"
  45. (drop)="onDrop($event)"
  46. >
  47. <!-- 附件胶囊区:放在输入框上方,与 ChatGPT 一致 -->
  48. <ng-content select="[attachments]" />
  49. <textarea
  50. #area
  51. class="input"
  52. [class.is-overflow]="overflowing()"
  53. rows="1"
  54. [placeholder]="placeholder()"
  55. [value]="draft()"
  56. [disabled]="disabled()"
  57. [attr.aria-label]="placeholder()"
  58. (input)="onInput($event)"
  59. (keydown)="onKeydown($event)"
  60. (paste)="onPaste($event)"
  61. (focus)="onFocus()"
  62. (blur)="keyboardFocus.set(false)"
  63. ></textarea>
  64. <!--
  65. 工具条(2026-10-29 对照 ChatGPT demo):左侧附件,右侧发送。
  66. 原来附件钮、输入框、发送钮三者同一行,输入框被压成一条 ——
  67. 多行输入时左右两个大按钮把文字挤在中间,是「简陋」的主要来源。
  68. -->
  69. <div class="composer-bar">
  70. <button
  71. type="button"
  72. class="attach"
  73. aria-label="添加附件"
  74. title="添加附件"
  75. [disabled]="disabled() || uploading()"
  76. (click)="picker.click()"
  77. >
  78. <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
  79. </button>
  80. <input
  81. #picker
  82. type="file"
  83. multiple
  84. hidden
  85. (change)="onPick($event)"
  86. aria-hidden="true"
  87. tabindex="-1"
  88. />
  89. <span class="bar-hint" aria-hidden="true">Enter 发送 · Shift + Enter 换行</span>
  90. <button
  91. type="submit"
  92. class="send"
  93. [class.ready]="canSend()"
  94. [class.busy]="sending()"
  95. [disabled]="!canSend() && !sending()"
  96. [attr.aria-label]="sending() ? '发送中' : '发送'"
  97. [attr.aria-disabled]="!canSend()"
  98. >
  99. @if (sending()) {
  100. <ui-spinner size="sm" label="发送中" />
  101. } @else {
  102. <i class="fa-solid fa-arrow-up" aria-hidden="true"></i>
  103. }
  104. </button>
  105. </div>
  106. </form>
  107. @if (uploading()) {
  108. <div class="upload" role="status" aria-live="polite">
  109. <i class="fa-solid fa-arrow-up-from-bracket" aria-hidden="true"></i>
  110. <span>正在上传… {{ pct() }}%</span>
  111. <ui-progress [value]="pct()" />
  112. </div>
  113. }
  114. `,
  115. styles: [
  116. `
  117. :host {
  118. display: block;
  119. }
  120. /*
  121. * 输入区整体:**圆角卡片浮起**,而不是一条通栏横条。
  122. * 通栏横条会把「对话流」和「输入区」割成上下两块,卡片式让输入区像一个可带走的对象。
  123. */
  124. .composer {
  125. display: grid;
  126. gap: var(--sp-1);
  127. margin: var(--sp-2) 0 var(--sp-4);
  128. padding: var(--sp-2);
  129. border: 1px solid var(--edge);
  130. border-radius: var(--r-lg);
  131. background: var(--surface);
  132. box-shadow: var(--shadow-soft);
  133. transition: border-color var(--motion-fast) var(--ease-standard),
  134. box-shadow var(--motion-fast) var(--ease-standard);
  135. }
  136. /*
  137. * 点击输入框时**不出现任何外层高亮**(2026-10-29 用户反馈):
  138. * 原来 focus-within 会加一圈更深的边框 + 投影,鼠标点进去后
  139. * 整个卡片像被"选中"了一样,视觉噪音大。
  140. * 卡片恒定外观,只在不处于默认态时给一点点提示(见下方 :focus-visible)。
  141. */
  142. /*
  143. * 🔴 只在键盘聚焦时给卡片加焦点环(kb-focus 类由 JS 判定,见 onFocus)。
  144. *
  145. * 为什么不用 CSS 伪类 focus-visible:**文本输入框永远满足该伪类**,
  146. * 鼠标点进去也算 —— 浏览器规范如此(输入框必须能看见光标位置)。
  147. * 实测确认:鼠标点击后 textarea.matches(':focus-visible') 为 true。
  148. * 所以只能用 JS 记录「最近一次交互是不是键盘」来区分。
  149. */
  150. /* 拖入文件时整块高亮:让"可以放这里"这件事有明确反馈 */
  151. .composer.is-dragover {
  152. border-color: var(--primary);
  153. background: var(--primary-subtle);
  154. box-shadow: 0 0 0 3px var(--primary-ring);
  155. }
  156. .composer.kb-focus {
  157. border-color: var(--primary);
  158. box-shadow: 0 0 0 3px var(--primary-ring);
  159. }
  160. .input {
  161. width: 100%;
  162. min-height: 44px;
  163. max-height: var(--composer-max-h, 200px);
  164. padding: var(--sp-2) var(--sp-3);
  165. border: 0;
  166. border-radius: var(--r-md);
  167. background: transparent;
  168. color: var(--ink);
  169. font-family: inherit;
  170. font-size: var(--fs-16);
  171. line-height: 1.6;
  172. resize: none;
  173. /*
  174. * 🔴 默认不显示滚动条,只在内容真正超出上限后才出现(AC-04.5)。
  175. * hidden + 由 syncHeight() 把 height 顶到 scrollHeight;超上限后才切 auto。
  176. */
  177. overflow-y: hidden;
  178. box-sizing: border-box;
  179. }
  180. .input.is-overflow {
  181. overflow-y: auto;
  182. }
  183. /*
  184. * 卡片本身已有 focus-within 边框高亮(鼠标点进来也能看到)。
  185. * 🔴 输入框只画 :focus-visible 的环 —— 键盘 Tab 进来有清晰焦点环,
  186. * 鼠标点进来不叠第二层矩形框(双框很难看)。
  187. * 禁止整体移除轮廓:那等于去掉焦点环,verify:ui 会直接拦下。
  188. */
  189. /*
  190. * 输入框自身不画焦点环 —— 环由外层卡片负责(见 .composer:has(.input:focus-visible))。
  191. * 鼠标点击时 :focus-visible 不成立,因此卡片保持原样,没有"外层状态"。
  192. * 焦点环统一由外层卡片承担,输入框本身保持透明轮廓即可。
  193. */
  194. .input:focus-visible {
  195. outline: 0 solid transparent;
  196. }
  197. .composer-bar {
  198. display: flex;
  199. align-items: center;
  200. gap: var(--sp-2);
  201. padding: 0 var(--sp-1) var(--sp-1);
  202. }
  203. .attach {
  204. display: grid;
  205. place-items: center;
  206. width: 32px;
  207. height: 32px;
  208. flex-shrink: 0;
  209. border: 0;
  210. border-radius: var(--r-tag);
  211. background: transparent;
  212. color: var(--muted);
  213. cursor: pointer;
  214. transition: background-color var(--motion-fast) var(--ease-standard);
  215. }
  216. .attach:hover:not(:disabled) {
  217. background: var(--surface-hover);
  218. color: var(--ink);
  219. }
  220. .attach:disabled {
  221. opacity: 0.4;
  222. cursor: not-allowed;
  223. }
  224. /* 快捷键提示:只在宽屏显示,窄屏会挤压发送键 */
  225. .bar-hint {
  226. flex: 1;
  227. min-width: 0;
  228. overflow: hidden;
  229. text-overflow: ellipsis;
  230. white-space: nowrap;
  231. font-size: var(--fs-11);
  232. color: var(--faint);
  233. pointer-events: none;
  234. }
  235. /*
  236. * 发送键三态(ChatGPT demo 的做法):
  237. * 灰(不可发)/ 实心主色(可发)/ 生成中
  238. */
  239. .send {
  240. display: grid;
  241. place-items: center;
  242. width: 32px;
  243. height: 32px;
  244. flex-shrink: 0;
  245. margin-left: auto;
  246. border: 0;
  247. border-radius: var(--r-tag);
  248. background: var(--surface-active);
  249. color: var(--faint);
  250. cursor: not-allowed;
  251. transition: background-color var(--motion-fast) var(--ease-standard),
  252. transform var(--motion-fast) var(--ease-standard);
  253. }
  254. .send.ready {
  255. background: var(--primary);
  256. color: var(--primary-content);
  257. cursor: pointer;
  258. }
  259. .send.ready:hover {
  260. background: var(--primary-hover);
  261. }
  262. .send.ready:active,
  263. .send.busy:active {
  264. transform: scale(0.94);
  265. }
  266. .send.busy {
  267. background: var(--primary);
  268. color: var(--primary-content);
  269. cursor: default;
  270. }
  271. .upload {
  272. display: flex;
  273. align-items: center;
  274. gap: var(--sp-2);
  275. margin: 0 0 var(--sp-3);
  276. padding: var(--sp-2) var(--sp-3);
  277. border-radius: var(--r-md);
  278. font-size: var(--fs-11);
  279. color: var(--muted);
  280. background: var(--surface-2);
  281. }
  282. .upload progress {
  283. flex: 1;
  284. height: 6px;
  285. }
  286. `,
  287. ],
  288. })
  289. export class ComposerComponent {
  290. readonly draft = input<string>('');
  291. readonly disabled = input(false);
  292. readonly sending = input(false);
  293. readonly uploading = input(false);
  294. readonly pct = input(0);
  295. /*
  296. * 占位文案只讲「要做什么」,快捷键说明放在工具条里 ——
  297. * 原来占位符把「Enter 发送 / Shift+Enter 换行」也写进去了,
  298. * 结果和工具条的提示重复出现两遍。
  299. */
  300. readonly placeholder = input('输入消息…');
  301. /** 输入框最小高度(px) —— 桌面 48 / 移动 30(原型口径) */
  302. readonly minHeight = input(48);
  303. /** 输入框最大高度(px) —— 桌面 160 / 移动 100(原型口径) */
  304. readonly maxHeight = input(160);
  305. /**
  306. * 是否「键盘聚焦」(用于决定要不要画焦点环)。
  307. *
  308. * 文本输入框永远满足 CSS `:focus-visible`(浏览器规范要求输入元素始终可见焦点),
  309. * 所以无法用纯 CSS 区分「鼠标点进来」和「Tab 进来」——
  310. * 只能在实际聚焦的那一刻,看最近一次用户输入是不是键盘。
  311. */
  312. protected readonly keyboardFocus = signal(false);
  313. /** 最近一次交互是否为键盘操作(全局监听一次,聚焦时读取) */
  314. private static lastInputWasKeyboard = false;
  315. private static listening = false;
  316. protected onFocus(): void {
  317. this.keyboardFocus.set(ComposerComponent.lastInputWasKeyboard);
  318. }
  319. /** 记录「上一次输入方式」。只挂一次,避免多实例重复监听。 */
  320. private static ensureInputListener(): void {
  321. if (ComposerComponent.listening || typeof window === 'undefined') return;
  322. ComposerComponent.listening = true;
  323. window.addEventListener(
  324. 'keydown',
  325. (e) => {
  326. // Tab / 方向键 / 任意字符都算键盘导航;Esc 不算(它是取消动作)
  327. ComposerComponent.lastInputWasKeyboard = e.key !== 'Escape';
  328. },
  329. true,
  330. );
  331. window.addEventListener(
  332. 'pointerdown',
  333. () => {
  334. ComposerComponent.lastInputWasKeyboard = false;
  335. },
  336. true,
  337. );
  338. }
  339. readonly draftChange = output<string>();
  340. readonly send = output<string>();
  341. readonly attach = output<FileList>();
  342. /**
  343. * 是否已有待发附件(由页面传入)。
  344. *
  345. * 🔴 用于放宽发送判定:只选图片、不打字时也应能发送。
  346. * 组件本身不持有附件状态,保持无状态。
  347. */
  348. readonly hasAttachments = input(false, { transform: booleanAttribute });
  349. /** 内容是否已超出 max-height(决定是否出现内部滚动条);模板绑定,须公开 */
  350. readonly overflowing = signal(false);
  351. private readonly area = viewChild<ElementRef<HTMLTextAreaElement>>('area');
  352. /** 文本非空 **或** 有待发附件即可发送(纯图片消息) */
  353. readonly canSend = computed(
  354. () =>
  355. (this.draft().trim().length > 0 || this.hasAttachments()) &&
  356. !this.disabled() &&
  357. !this.sending(),
  358. );
  359. constructor() {
  360. // 外部清空草稿(发送成功后)时同步高度
  361. effect(() => {
  362. this.draft();
  363. this.syncHeight();
  364. });
  365. /*
  366. * 🔴 监听必须在**构造时**就挂上,不能等到聚焦再挂 ——
  367. * 聚焦事件发生在 keydown 之后,那时早已错过记录输入方式(实测踩坑)。
  368. */
  369. ComposerComponent.ensureInputListener();
  370. }
  371. onInput(e: Event): void {
  372. const el = e.target as HTMLTextAreaElement;
  373. this.draftChange.emit(el.value);
  374. this.syncHeight(el);
  375. }
  376. onSubmit(e: Event): void {
  377. e.preventDefault();
  378. this.emitSend();
  379. }
  380. /** AC-04.5:Enter 发送,Shift+Enter 换行 */
  381. onKeydown(e: KeyboardEvent): void {
  382. if (e.key !== 'Enter') return;
  383. if (e.shiftKey) return; // 换行交给浏览器默认行为
  384. e.preventDefault();
  385. this.emitSend();
  386. }
  387. private emitSend(): void {
  388. if (!this.canSend()) return;
  389. this.send.emit(this.draft());
  390. }
  391. /** 当前是否有文件被拖到输入区上方(用于高亮提示) */
  392. protected readonly dragging = signal(false);
  393. /**
  394. * 拖拽进入:必须 preventDefault,否则浏览器会把它当成"打开文件"的默认行为,
  395. * 直接把页面跳走 —— 用户拖进来一张图,整个应用没了。
  396. */
  397. protected onDragOver(e: DragEvent): void {
  398. if (!e.dataTransfer?.types.includes('Files')) return;
  399. e.preventDefault();
  400. e.dataTransfer.dropEffect = 'copy';
  401. this.dragging.set(true);
  402. }
  403. protected onDragLeave(e: DragEvent): void {
  404. /*
  405. * 只在**真正离开整个表单**时取消高亮。
  406. * 子元素之间的移动也会触发 dragleave,直接置 false 会闪烁。
  407. */
  408. const to = e.relatedTarget as Node | null;
  409. if (to && (e.currentTarget as HTMLElement).contains(to)) return;
  410. this.dragging.set(false);
  411. }
  412. protected onDrop(e: DragEvent): void {
  413. e.preventDefault();
  414. this.dragging.set(false);
  415. const files = e.dataTransfer?.files;
  416. if (files && files.length > 0) this.attach.emit(files);
  417. }
  418. /**
  419. * 粘贴上传:从剪贴板里取出文件(截图工具复制的是图片 Blob)。
  420. *
  421. * 只处理**带文件**的粘贴;纯文本粘贴保持默认行为(要能正常贴文字)。
  422. */
  423. protected onPaste(e: ClipboardEvent): void {
  424. const items = e.clipboardData?.items;
  425. if (!items) return;
  426. const files: File[] = [];
  427. for (const it of Array.from(items)) {
  428. if (it.kind !== 'file') continue;
  429. const f = it.getAsFile();
  430. if (f) files.push(f);
  431. }
  432. if (files.length === 0) return;
  433. /* 有文件时才拦截默认粘贴,避免把图片当成二进制塞进 textarea */
  434. e.preventDefault();
  435. const dt = new DataTransfer();
  436. for (const f of files) dt.items.add(f);
  437. this.attach.emit(dt.files);
  438. }
  439. onPick(e: Event): void {
  440. const input = e.target as HTMLInputElement;
  441. if (input.files && input.files.length > 0) {
  442. this.attach.emit(input.files);
  443. }
  444. input.value = ''; // 允许重复选择同一文件
  445. }
  446. /** 真实自增高:测量 scrollHeight 后同步 height */
  447. private syncHeight(el?: HTMLTextAreaElement): void {
  448. const target = el ?? this.area()?.nativeElement;
  449. if (!target) return;
  450. target.style.height = 'auto';
  451. const full = target.scrollHeight;
  452. const capped = Math.min(full, this.maxHeight());
  453. target.style.height = `${capped}px`;
  454. // 内容确实超过上限才允许内部滚动(1px 容差吸收舍入误差)
  455. this.overflowing.set(full > capped + 1);
  456. }
  457. }