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);
}
}