| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488 |
- import {
- ChangeDetectionStrategy,
- Component,
- booleanAttribute,
- ElementRef,
- computed,
- effect,
- input,
- output,
- signal,
- viewChild,
- } from '@angular/core';
- import { UiProgress, UiSpinner } from '../../../ui';
- /**
- * 消息输入栏 —— 自建(《组件规范》§四-2)
- *
- * 覆盖 SPEC FR-04:
- * AC-04.5 Enter 发送 / Shift+Enter 换行 / **真实自增高**(`scrollHeight` 驱动)
- * AC-04.3 附件入口 + 上传进度可见
- * AC-04.8 发送中禁用,不产生并发
- *
- * 🔴 「真实自增高」= 每次输入后测量 `scrollHeight` 并同步 `height`,
- * 而不是只设 `rows` 或 `field-sizing`(浏览器支持不一致)。
- * 高度上限由 max-height 控制,超出后内部滚动。
- */
- @Component({
- selector: 'app-composer',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [UiSpinner, UiProgress],
- template: `
- <!--
- 粘贴 / 拖拽上传(2026-10-30):
- · 在输入框里 Ctrl+V 粘贴图片(截图工具直接粘)→ 自动加入待发送附件
- · 把文件拖到输入区上 → 松手即加入;拖入时整块区高亮提示可放
- 两者都走与「选择文件」相同的 attach 输出,后续链路完全一致。
- -->
- <form
- class="composer"
- [class.kb-focus]="keyboardFocus()"
- [class.is-busy]="sending()"
- [class.is-dragover]="dragging()"
- (submit)="onSubmit($event)"
- (dragover)="onDragOver($event)"
- (dragleave)="onDragLeave($event)"
- (drop)="onDrop($event)"
- >
- <!-- 附件胶囊区:放在输入框上方,与 ChatGPT 一致 -->
- <ng-content select="[attachments]" />
- <textarea
- #area
- class="input"
- [class.is-overflow]="overflowing()"
- rows="1"
- [placeholder]="placeholder()"
- [value]="draft()"
- [disabled]="disabled()"
- [attr.aria-label]="placeholder()"
- (input)="onInput($event)"
- (keydown)="onKeydown($event)"
- (paste)="onPaste($event)"
- (focus)="onFocus()"
- (blur)="keyboardFocus.set(false)"
- ></textarea>
- <!--
- 工具条(2026-10-29 对照 ChatGPT demo):左侧附件,右侧发送。
- 原来附件钮、输入框、发送钮三者同一行,输入框被压成一条 ——
- 多行输入时左右两个大按钮把文字挤在中间,是「简陋」的主要来源。
- -->
- <div class="composer-bar">
- <button
- type="button"
- class="attach"
- aria-label="添加附件"
- title="添加附件"
- [disabled]="disabled() || uploading()"
- (click)="picker.click()"
- >
- <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
- </button>
- <input
- #picker
- type="file"
- multiple
- hidden
- (change)="onPick($event)"
- aria-hidden="true"
- tabindex="-1"
- />
- <span class="bar-hint" aria-hidden="true">Enter 发送 · Shift + Enter 换行</span>
- <button
- type="submit"
- class="send"
- [class.ready]="canSend()"
- [class.busy]="sending()"
- [disabled]="!canSend() && !sending()"
- [attr.aria-label]="sending() ? '发送中' : '发送'"
- [attr.aria-disabled]="!canSend()"
- >
- @if (sending()) {
- <ui-spinner size="sm" label="发送中" />
- } @else {
- <i class="fa-solid fa-arrow-up" aria-hidden="true"></i>
- }
- </button>
- </div>
- </form>
- @if (uploading()) {
- <div class="upload" role="status" aria-live="polite">
- <i class="fa-solid fa-arrow-up-from-bracket" aria-hidden="true"></i>
- <span>正在上传… {{ pct() }}%</span>
- <ui-progress [value]="pct()" />
- </div>
- }
- `,
- styles: [
- `
- :host {
- display: block;
- }
- /*
- * 输入区整体:**圆角卡片浮起**,而不是一条通栏横条。
- * 通栏横条会把「对话流」和「输入区」割成上下两块,卡片式让输入区像一个可带走的对象。
- */
- .composer {
- display: grid;
- gap: var(--sp-1);
- margin: var(--sp-2) 0 var(--sp-4);
- padding: var(--sp-2);
- border: 1px solid var(--edge);
- border-radius: var(--r-lg);
- background: var(--surface);
- box-shadow: var(--shadow-soft);
- transition: border-color var(--motion-fast) var(--ease-standard),
- box-shadow var(--motion-fast) var(--ease-standard);
- }
- /*
- * 点击输入框时**不出现任何外层高亮**(2026-10-29 用户反馈):
- * 原来 focus-within 会加一圈更深的边框 + 投影,鼠标点进去后
- * 整个卡片像被"选中"了一样,视觉噪音大。
- * 卡片恒定外观,只在不处于默认态时给一点点提示(见下方 :focus-visible)。
- */
- /*
- * 🔴 只在键盘聚焦时给卡片加焦点环(kb-focus 类由 JS 判定,见 onFocus)。
- *
- * 为什么不用 CSS 伪类 focus-visible:**文本输入框永远满足该伪类**,
- * 鼠标点进去也算 —— 浏览器规范如此(输入框必须能看见光标位置)。
- * 实测确认:鼠标点击后 textarea.matches(':focus-visible') 为 true。
- * 所以只能用 JS 记录「最近一次交互是不是键盘」来区分。
- */
- /* 拖入文件时整块高亮:让"可以放这里"这件事有明确反馈 */
- .composer.is-dragover {
- border-color: var(--primary);
- background: var(--primary-subtle);
- box-shadow: 0 0 0 3px var(--primary-ring);
- }
- .composer.kb-focus {
- border-color: var(--primary);
- box-shadow: 0 0 0 3px var(--primary-ring);
- }
- .input {
- width: 100%;
- min-height: 44px;
- max-height: var(--composer-max-h, 200px);
- padding: var(--sp-2) var(--sp-3);
- border: 0;
- border-radius: var(--r-md);
- background: transparent;
- color: var(--ink);
- font-family: inherit;
- font-size: var(--fs-16);
- line-height: 1.6;
- resize: none;
- /*
- * 🔴 默认不显示滚动条,只在内容真正超出上限后才出现(AC-04.5)。
- * hidden + 由 syncHeight() 把 height 顶到 scrollHeight;超上限后才切 auto。
- */
- overflow-y: hidden;
- box-sizing: border-box;
- }
- .input.is-overflow {
- overflow-y: auto;
- }
- /*
- * 卡片本身已有 focus-within 边框高亮(鼠标点进来也能看到)。
- * 🔴 输入框只画 :focus-visible 的环 —— 键盘 Tab 进来有清晰焦点环,
- * 鼠标点进来不叠第二层矩形框(双框很难看)。
- * 禁止整体移除轮廓:那等于去掉焦点环,verify:ui 会直接拦下。
- */
- /*
- * 输入框自身不画焦点环 —— 环由外层卡片负责(见 .composer:has(.input:focus-visible))。
- * 鼠标点击时 :focus-visible 不成立,因此卡片保持原样,没有"外层状态"。
- * 焦点环统一由外层卡片承担,输入框本身保持透明轮廓即可。
- */
- .input:focus-visible {
- outline: 0 solid transparent;
- }
- .composer-bar {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- padding: 0 var(--sp-1) var(--sp-1);
- }
- .attach {
- display: grid;
- place-items: center;
- width: 32px;
- height: 32px;
- flex-shrink: 0;
- border: 0;
- border-radius: var(--r-tag);
- background: transparent;
- color: var(--muted);
- cursor: pointer;
- transition: background-color var(--motion-fast) var(--ease-standard);
- }
- .attach:hover:not(:disabled) {
- background: var(--surface-hover);
- color: var(--ink);
- }
- .attach:disabled {
- opacity: 0.4;
- cursor: not-allowed;
- }
- /* 快捷键提示:只在宽屏显示,窄屏会挤压发送键 */
- .bar-hint {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: var(--fs-11);
- color: var(--faint);
- pointer-events: none;
- }
- /*
- * 发送键三态(ChatGPT demo 的做法):
- * 灰(不可发)/ 实心主色(可发)/ 生成中
- */
- .send {
- display: grid;
- place-items: center;
- width: 32px;
- height: 32px;
- flex-shrink: 0;
- margin-left: auto;
- border: 0;
- border-radius: var(--r-tag);
- background: var(--surface-active);
- color: var(--faint);
- cursor: not-allowed;
- transition: background-color var(--motion-fast) var(--ease-standard),
- transform var(--motion-fast) var(--ease-standard);
- }
- .send.ready {
- background: var(--primary);
- color: var(--primary-content);
- cursor: pointer;
- }
- .send.ready:hover {
- background: var(--primary-hover);
- }
- .send.ready:active,
- .send.busy:active {
- transform: scale(0.94);
- }
- .send.busy {
- background: var(--primary);
- color: var(--primary-content);
- cursor: default;
- }
- .upload {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin: 0 0 var(--sp-3);
- padding: var(--sp-2) var(--sp-3);
- border-radius: var(--r-md);
- font-size: var(--fs-11);
- color: var(--muted);
- background: var(--surface-2);
- }
- .upload progress {
- flex: 1;
- height: 6px;
- }
- `,
- ],
- })
- export class ComposerComponent {
- readonly draft = input<string>('');
- readonly disabled = input(false);
- readonly sending = input(false);
- readonly uploading = input(false);
- readonly pct = input(0);
- /*
- * 占位文案只讲「要做什么」,快捷键说明放在工具条里 ——
- * 原来占位符把「Enter 发送 / Shift+Enter 换行」也写进去了,
- * 结果和工具条的提示重复出现两遍。
- */
- readonly placeholder = input('输入消息…');
- /** 输入框最小高度(px) —— 桌面 48 / 移动 30(原型口径) */
- readonly minHeight = input(48);
- /** 输入框最大高度(px) —— 桌面 160 / 移动 100(原型口径) */
- readonly maxHeight = input(160);
- /**
- * 是否「键盘聚焦」(用于决定要不要画焦点环)。
- *
- * 文本输入框永远满足 CSS `:focus-visible`(浏览器规范要求输入元素始终可见焦点),
- * 所以无法用纯 CSS 区分「鼠标点进来」和「Tab 进来」——
- * 只能在实际聚焦的那一刻,看最近一次用户输入是不是键盘。
- */
- protected readonly keyboardFocus = signal(false);
- /** 最近一次交互是否为键盘操作(全局监听一次,聚焦时读取) */
- private static lastInputWasKeyboard = false;
- private static listening = false;
- protected onFocus(): void {
- this.keyboardFocus.set(ComposerComponent.lastInputWasKeyboard);
- }
- /** 记录「上一次输入方式」。只挂一次,避免多实例重复监听。 */
- private static ensureInputListener(): void {
- if (ComposerComponent.listening || typeof window === 'undefined') return;
- ComposerComponent.listening = true;
- window.addEventListener(
- 'keydown',
- (e) => {
- // Tab / 方向键 / 任意字符都算键盘导航;Esc 不算(它是取消动作)
- ComposerComponent.lastInputWasKeyboard = e.key !== 'Escape';
- },
- true,
- );
- window.addEventListener(
- 'pointerdown',
- () => {
- ComposerComponent.lastInputWasKeyboard = false;
- },
- true,
- );
- }
- readonly draftChange = output<string>();
- readonly send = output<string>();
- readonly attach = output<FileList>();
- /**
- * 是否已有待发附件(由页面传入)。
- *
- * 🔴 用于放宽发送判定:只选图片、不打字时也应能发送。
- * 组件本身不持有附件状态,保持无状态。
- */
- readonly hasAttachments = input(false, { transform: booleanAttribute });
- /** 内容是否已超出 max-height(决定是否出现内部滚动条);模板绑定,须公开 */
- readonly overflowing = signal(false);
- private readonly area = viewChild<ElementRef<HTMLTextAreaElement>>('area');
- /** 文本非空 **或** 有待发附件即可发送(纯图片消息) */
- readonly canSend = computed(
- () =>
- (this.draft().trim().length > 0 || this.hasAttachments()) &&
- !this.disabled() &&
- !this.sending(),
- );
- constructor() {
- // 外部清空草稿(发送成功后)时同步高度
- effect(() => {
- this.draft();
- this.syncHeight();
- });
- /*
- * 🔴 监听必须在**构造时**就挂上,不能等到聚焦再挂 ——
- * 聚焦事件发生在 keydown 之后,那时早已错过记录输入方式(实测踩坑)。
- */
- ComposerComponent.ensureInputListener();
- }
- onInput(e: Event): void {
- const el = e.target as HTMLTextAreaElement;
- this.draftChange.emit(el.value);
- this.syncHeight(el);
- }
- onSubmit(e: Event): void {
- e.preventDefault();
- this.emitSend();
- }
- /** AC-04.5:Enter 发送,Shift+Enter 换行 */
- onKeydown(e: KeyboardEvent): void {
- if (e.key !== 'Enter') return;
- if (e.shiftKey) return; // 换行交给浏览器默认行为
- e.preventDefault();
- this.emitSend();
- }
- private emitSend(): void {
- if (!this.canSend()) return;
- this.send.emit(this.draft());
- }
- /** 当前是否有文件被拖到输入区上方(用于高亮提示) */
- protected readonly dragging = signal(false);
- /**
- * 拖拽进入:必须 preventDefault,否则浏览器会把它当成"打开文件"的默认行为,
- * 直接把页面跳走 —— 用户拖进来一张图,整个应用没了。
- */
- protected onDragOver(e: DragEvent): void {
- if (!e.dataTransfer?.types.includes('Files')) return;
- e.preventDefault();
- e.dataTransfer.dropEffect = 'copy';
- this.dragging.set(true);
- }
- protected onDragLeave(e: DragEvent): void {
- /*
- * 只在**真正离开整个表单**时取消高亮。
- * 子元素之间的移动也会触发 dragleave,直接置 false 会闪烁。
- */
- const to = e.relatedTarget as Node | null;
- if (to && (e.currentTarget as HTMLElement).contains(to)) return;
- this.dragging.set(false);
- }
- protected onDrop(e: DragEvent): void {
- e.preventDefault();
- this.dragging.set(false);
- const files = e.dataTransfer?.files;
- if (files && files.length > 0) this.attach.emit(files);
- }
- /**
- * 粘贴上传:从剪贴板里取出文件(截图工具复制的是图片 Blob)。
- *
- * 只处理**带文件**的粘贴;纯文本粘贴保持默认行为(要能正常贴文字)。
- */
- protected onPaste(e: ClipboardEvent): void {
- const items = e.clipboardData?.items;
- if (!items) return;
- const files: File[] = [];
- for (const it of Array.from(items)) {
- if (it.kind !== 'file') continue;
- const f = it.getAsFile();
- if (f) files.push(f);
- }
- if (files.length === 0) return;
- /* 有文件时才拦截默认粘贴,避免把图片当成二进制塞进 textarea */
- e.preventDefault();
- const dt = new DataTransfer();
- for (const f of files) dt.items.add(f);
- this.attach.emit(dt.files);
- }
- onPick(e: Event): void {
- const input = e.target as HTMLInputElement;
- if (input.files && input.files.length > 0) {
- this.attach.emit(input.files);
- }
- input.value = ''; // 允许重复选择同一文件
- }
- /** 真实自增高:测量 scrollHeight 后同步 height */
- private syncHeight(el?: HTMLTextAreaElement): void {
- const target = el ?? this.area()?.nativeElement;
- if (!target) return;
- target.style.height = 'auto';
- const full = target.scrollHeight;
- const capped = Math.min(full, this.maxHeight());
- target.style.height = `${capped}px`;
- // 内容确实超过上限才允许内部滚动(1px 容差吸收舍入误差)
- this.overflowing.set(full > capped + 1);
- }
- }
|