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: `
{{ copyTip() }} `, 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(''); /** 紧凑模式(用于气泡内) */ readonly compact = input(false); readonly copyTip = signal(''); private readonly host = viewChild.required>('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 { try { await navigator.clipboard.writeText(text); this.copyTip.set('已复制'); } catch { // 剪贴板不可用(非 https / 无权限)→ 明确告知,不静默失败 this.copyTip.set('复制失败:浏览器未授权剪贴板'); } setTimeout(() => this.copyTip.set(''), 2000); } }