| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- import { ChangeDetectionStrategy, Component, ElementRef, input, signal, viewChild } from '@angular/core';
- import { MarkdownComponent } from 'ngx-markdown';
- /**
- * Markdown 渲染 —— 消息正文与附件共用
- *
- * 依据 SPEC FR-04(AC-04.2)。技术栈与参考系统 `training-system` 一致
- * (`ngx-markdown` + `prismjs`)。
- *
- * ## 为何放在独立组件里 + 调用方用 `@defer`
- *
- * `ngx-markdown` + `prismjs` 约 **264 kB(gzip 72 kB)**。
- * 本组件是**同步**引用(`message-bubble` → 本组件),故 md 库会随首屏打包。
- *
- * 🔴 **减载由调用方负责**:父模板用 Angular 原生 `@defer` 包裹本组件,
- * 触发时才加载其依赖(含 md 库)。这样既保持本组件写法简单,
- * 又让 md 库不阻塞首屏。
- *
- * ## 安全(不可信输入)
- *
- * agent 回复经 Matrix 房间传来 → **不可信**。故:
- * · `[data]` 只传**纯文本**(不传 HTML)
- * · 🔗 链接强制 `target="_blank" rel="noopener noreferrer"`
- */
- @Component({
- selector: 'app-md-render',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [MarkdownComponent],
- template: `
- <div class="md-host" #host [class.md-host--compact]="compact()">
- <markdown [data]="text()" (ready)="onReady()" />
- </div>
- <span class="md-copy-live" role="status" aria-live="polite">{{ copyTip() }}</span>
- `,
- styles: [
- `
- :host {
- display: block;
- /*
- * 🔴 必须建立定位上下文。
- *
- * 屏读提示 .md-copy-live 是 position:absolute;宿主若为 static,
- * 它会以**初始包含块**为基准 → 每条消息一个绝对定位元素堆在文档底部,
- * 把 document.scrollHeight 从 950 撑到 2997px,
- * 浏览器里表现为「最外层能滚出一大段空白」(用户实测的异常滚动)。
- */
- position: relative;
- }
- .md-host {
- font-size: var(--fs-15);
- line-height: 1.7;
- color: var(--ink);
- word-break: break-word;
- overflow-wrap: anywhere;
- }
- .md-host--compact {
- font-size: var(--fs-13);
- }
- /* 🔴 因为要命中 ngx-markdown 动态插入的节点,以下用 :host ::ng-deep 收敛作用域,
- 避免关闭 ViewEncapsulation 污染全局 */
- :host ::ng-deep .md-host :is(h1, h2, h3, h4, h5, h6) {
- margin: var(--sp-3) 0 var(--sp-2);
- font-weight: 600;
- line-height: 1.3;
- color: var(--ink);
- }
- :host ::ng-deep .md-host :is(h1, h2) {
- font-size: var(--fs-17);
- }
- :host ::ng-deep .md-host :is(h3, h4) {
- font-size: var(--fs-15);
- }
- :host ::ng-deep .md-host :is(h5, h6) {
- font-size: var(--fs-13);
- }
- :host ::ng-deep .md-host > :first-child {
- margin-top: 0;
- }
- :host ::ng-deep .md-host > :last-child {
- margin-bottom: 0;
- }
- :host ::ng-deep .md-host p {
- margin: 0 0 var(--sp-2);
- }
- :host ::ng-deep .md-host :is(ul, ol) {
- margin: 0 0 var(--sp-2);
- padding-left: 1.4em;
- }
- :host ::ng-deep .md-host li {
- margin: 2px 0;
- }
- :host ::ng-deep .md-host blockquote {
- margin: var(--sp-2) 0;
- padding: var(--sp-2) var(--sp-3);
- border-left: 3px solid var(--edge-strong);
- background: var(--surface-2);
- color: var(--ink-2);
- }
- :host ::ng-deep .md-host code {
- padding: 1px 5px;
- border-radius: var(--r-tag);
- background: var(--surface-2);
- font-size: 0.92em;
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- }
- /*
- 代码块:**深底 + 亮字**。
- 语法高亮用的是 prism-okaidia(暗色主题,亮色文字),
- 此前挂在亮底 --surface-2 上 → 亮底亮字几乎看不清(用户实测)。
- 两种正确组合:深底亮字 / 亮底深字。这里固定用深底,暗色模式下也一致。
- */
- :host ::ng-deep .md-host .md-code {
- position: relative;
- margin: var(--sp-2) 0;
- border-radius: var(--r-md);
- background: var(--nav-surface);
- }
- :host ::ng-deep .md-host pre {
- margin: 0;
- padding: var(--sp-3);
- border-radius: var(--r-md);
- background: transparent;
- /* 只有代码块本体横滚,复制按钮在外层、不跟着滚 */
- overflow-x: auto;
- }
- :host ::ng-deep .md-host pre code {
- padding: 0;
- background: none;
- color: var(--nav-ink);
- font-size: var(--fs-13);
- line-height: 1.6;
- }
- /* 复制按钮:钉在**外层容器**右上角,代码横滚时保持不动 */
- :host ::ng-deep .md-host .md-copy-btn {
- position: absolute;
- top: 6px;
- right: 6px;
- z-index: 1;
- min-height: 26px;
- padding: 0 8px;
- border: 1px solid var(--nav-line);
- border-radius: var(--r-tag);
- background: var(--nav-hover);
- color: var(--nav-text);
- font-size: var(--fs-12);
- cursor: pointer;
- }
- :host ::ng-deep .md-host .md-copy-btn:hover {
- color: var(--nav-ink);
- border-color: var(--nav-muted);
- background: var(--nav-active);
- }
- :host ::ng-deep .md-host table {
- width: 100%;
- margin: var(--sp-2) 0;
- border-collapse: collapse;
- font-size: var(--fs-13);
- }
- :host ::ng-deep .md-host :is(th, td) {
- padding: 6px 10px;
- border: 1px solid var(--edge);
- text-align: left;
- }
- :host ::ng-deep .md-host th {
- background: var(--surface-2);
- font-weight: 600;
- }
- :host ::ng-deep .md-host a {
- color: var(--primary);
- text-decoration: underline;
- text-underline-offset: 2px;
- }
- :host ::ng-deep .md-host img {
- max-width: 100%;
- height: auto;
- border-radius: var(--r-sm);
- }
- :host ::ng-deep .md-host hr {
- margin: var(--sp-3) 0;
- border: 0;
- border-top: 1px solid var(--edge);
- }
- .md-copy-live {
- position: absolute;
- width: 1px;
- height: 1px;
- overflow: hidden;
- clip-path: inset(50%);
- white-space: nowrap;
- }
- `,
- ],
- })
- export class MdRenderComponent {
- /** markdown 原文(**纯文本**,不是 HTML) */
- readonly text = input<string>('');
- /** 紧凑模式(用于气泡内) */
- readonly compact = input<boolean>(false);
- readonly copyTip = signal('');
- private readonly host = viewChild.required<ElementRef<HTMLElement>>('host');
- /**
- * 渲染完成后注入交互(① 代码块复制按钮 ② 链接外部安全打开)。
- *
- * ⚠️ 必须在 `(ready)` 之后 —— ngx-markdown 异步渲染,构造期查 DOM 会拿到空节点。
- */
- onReady(): void {
- queueMicrotask(() => this.enhance());
- }
- private enhance(): void {
- const root = this.host()?.nativeElement;
- if (!root) return;
- root.querySelectorAll('a[href]').forEach((a) => {
- const el = a as HTMLAnchorElement;
- if (!el.target) {
- el.target = '_blank';
- el.rel = 'noopener noreferrer';
- }
- });
- root.querySelectorAll('pre').forEach((pre) => {
- if (pre.parentElement?.classList.contains('md-code')) return; // 幂等
- /*
- * 🔴 按钮不能挂在 pre 里:pre 是横向滚动容器,
- * absolute 定位会**跟着内容一起滚走**(用户实测)。
- * 外层包一个不滚动的 .md-code,按钮挂在它上面,代码在 pre 里滚。
- */
- const wrap = document.createElement('div');
- wrap.className = 'md-code';
- pre.parentNode?.insertBefore(wrap, pre);
- wrap.appendChild(pre);
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'md-copy-btn';
- btn.textContent = '复制';
- btn.setAttribute('aria-label', '复制代码');
- btn.addEventListener('click', () => {
- const code = pre.querySelector('code')?.textContent ?? '';
- void this.copy(code);
- });
- wrap.appendChild(btn);
- });
- }
- private async copy(text: string): Promise<void> {
- try {
- await navigator.clipboard.writeText(text);
- this.copyTip.set('已复制');
- } catch {
- // 剪贴板不可用(非 https / 无权限)→ 明确告知,不静默失败
- this.copyTip.set('复制失败:浏览器未授权剪贴板');
- }
- setTimeout(() => this.copyTip.set(''), 2000);
- }
- }
|