feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,49 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { capitalize, getNodeList, getString, sanitizeClassToken } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-admonition-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<div class="markstream-nested-admonition" [class]="kindClass">
<div class="markstream-nested-admonition__title">{{ resolvedTitle }}</div>
<div class="markstream-nested-admonition__content">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdmonitionNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get kind() {
return sanitizeClassToken(getString((this.node as any)?.kind || 'note')) || 'note'
}
get kindClass() {
return `markstream-nested-admonition--${this.kind}`
}
get resolvedTitle() {
return getString((this.node as any)?.title) || capitalize(this.kind)
}
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'admonition'}-children`
}
}
@@ -0,0 +1,46 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-blockquote-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<blockquote class="blockquote-node" dir="auto" [attr.cite]="cite">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</blockquote>
`,
styles: [`
.blockquote-node :where(.markstream-angular) {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0 0;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlockquoteNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get cite() {
const value = (this.node as any)?.cite
return typeof value === 'string' && value.trim() ? value : null
}
get nestedPrefix() {
return `${this.indexKey || 'blockquote'}-quote`
}
}
@@ -0,0 +1,16 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
@Component({
selector: 'markstream-angular-checkbox-node',
standalone: true,
template: '<input type="checkbox" disabled [checked]="checked">',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckboxNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get checked() {
return !!(this.node as any)?.checked
}
}
@@ -0,0 +1,824 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
ViewChild,
} from '@angular/core'
import { useSafeI18n } from '../../i18n/useSafeI18n'
import { getUseMonaco } from '../../optional/monaco'
import { getLanguageIcon, languageMap, normalizeLanguageIdentifier, resolveMonacoLanguageId } from '../../utils/languageIcon'
import { PreCodeNodeComponent } from '../PreCodeNode/PreCodeNode.component'
import { getString } from '../shared/node-helpers'
import { HtmlPreviewFrameComponent } from './HtmlPreviewFrame.component'
interface MonacoHelpers {
createEditor?: (container: HTMLElement, code: string, language: string) => Promise<unknown> | unknown
createDiffEditor?: (container: HTMLElement, original: string, modified: string, language: string) => Promise<unknown> | unknown
updateCode?: (code: string, language?: string) => Promise<unknown> | unknown
updateDiff?: (original: string, modified: string, language?: string) => Promise<unknown> | unknown
cleanupEditor?: () => void
safeClean?: () => void
getEditorView?: () => any
getDiffEditorView?: () => any
setTheme?: (theme?: string) => Promise<unknown> | unknown
}
@Component({
selector: 'markstream-angular-code-block-node',
standalone: true,
imports: [CommonModule, PreCodeNodeComponent, HtmlPreviewFrameComponent],
template: `
<div
class="code-block-container"
[class.is-dark]="resolvedIsDark"
[class.is-plain-text]="isPlainTextLanguage"
[class.is-rendering]="resolvedLoading"
[attr.data-markstream-monaco]="editorReady && !useFallback ? '1' : null"
[attr.data-markstream-monaco-diff]="editorReady && isDiff && !useFallback ? '1' : null"
[ngStyle]="containerStyle"
>
<div
*ngIf="resolvedShowHeader"
class="code-block-header"
[style.color]="headerForeground"
[style.backgroundColor]="headerBackground"
>
<div class="code-block-header__meta">
<span class="icon-slot code-block-language-icon">
<img class="code-block-language-icon__image" [src]="languageIconDataUrl" alt="" />
</span>
<span class="code-block-header__label">{{ displayLanguage }}</span>
</div>
<div class="code-block-header__actions">
<button
*ngIf="resolvedShowCollapseButton"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(collapsed ? t('common.expand') : t('common.collapse'))"
[attr.aria-pressed]="collapsed"
(click)="toggleCollapsed()"
>
<svg
[style.rotate]="collapsed ? '0deg' : '90deg'"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="code-action-btn__icon"
>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 18l6-6l-6-6" />
</svg>
</button>
<ng-container *ngIf="resolvedShowFontSizeButtons && resolvedEnableFontSizeControl">
<button
type="button"
class="code-action-btn"
[disabled]="fontSize <= 10"
[attr.title]="tooltipFor(t('common.decrease'))"
(click)="changeFontSize(-1)"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14" />
</svg>
</button>
<button
type="button"
class="code-action-btn"
[disabled]="fontSize === defaultFontSize"
[attr.title]="tooltipFor(t('common.reset'))"
(click)="resetFontSize()"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</g>
</svg>
</button>
<button
type="button"
class="code-action-btn"
[disabled]="fontSize >= 36"
[attr.title]="tooltipFor(t('common.increase'))"
(click)="changeFontSize(1)"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7v14" />
</svg>
</button>
</ng-container>
<button
*ngIf="resolvedShowCopyButton"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(copied ? t('common.copied') : t('common.copy'))"
[attr.aria-label]="copied ? t('common.copied') : t('common.copy')"
(click)="copyCode()"
>
<svg *ngIf="!copied" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</g>
</svg>
<svg *ngIf="copied" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5" />
</svg>
</button>
<button
*ngIf="resolvedShowExpandButton"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(expanded ? t('common.collapse') : t('common.expand'))"
[attr.aria-pressed]="expanded"
(click)="toggleExpanded()"
>
<svg *ngIf="expanded" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 10l7-7m-1 7h-6V4M3 21l7-7m-6 0h6v6" />
</svg>
<svg *ngIf="!expanded" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 3h6v6m0-6l-7 7M3 21l7-7m-1 7H3v-6" />
</svg>
</button>
<button
*ngIf="resolvedShowPreviewButton && isPreviewable"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(t('common.preview'))"
[attr.aria-label]="t('common.preview')"
(click)="previewCode()"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true" class="code-action-btn__icon">
<g fill="currentColor" fill-rule="evenodd" clip-rule="evenodd">
<path d="M23.628 7.41c-.12-1.172-.08-3.583-.9-4.233c-1.921-1.51-6.143-1.11-8.815-1.19c-3.481-.15-7.193.14-10.625.24a.34.34 0 0 0 0 .67c3.472-.05 7.074-.29 10.575-.09c2.471.15 6.653-.14 8.254 1.16c.4.33.41 2.732.49 3.582a42 42 0 0 1 .08 9.005a13.8 13.8 0 0 1-.45 3.001c-2.42 1.4-19.69 2.381-20.72.55a21 21 0 0 1-.65-4.632a41.5 41.5 0 0 1 .12-7.964c.08 0 7.334.33 12.586.24c2.331 0 4.682-.13 6.764-.21a.33.33 0 0 0 0-.66c-7.714-.16-12.897-.43-19.31.05c.11-1.38.48-3.922.38-4.002a.3.3 0 0 0-.42 0c-.37.41-.29 1.77-.36 2.251s-.14 1.07-.2 1.6a45 45 0 0 0-.36 8.645a21.8 21.8 0 0 0 .66 5.002c1.46 2.702 17.248 1.461 20.95.43c1.45-.4 1.69-.8 1.871-1.95c.575-3.809.602-7.68.08-11.496" />
<path d="M4.528 5.237a.84.84 0 0 0-.21-1c-.77-.41-1.71.39-1 1.1a.83.83 0 0 0 1.21-.1m2.632-.25c.14-.14.19-.84-.2-1c-.77-.41-1.71.39-1 1.09a.82.82 0 0 0 1.2-.09m2.88 0a.83.83 0 0 0-.21-1c-.77-.41-1.71.39-1 1.09a.82.82 0 0 0 1.21-.09m-4.29 8.735c0 .08.23 2.471.31 2.561a.371.371 0 0 0 .63-.14c0-.09 0 0 .15-1.72a10 10 0 0 0-.11-2.232a5.3 5.3 0 0 1-.26-1.37a.3.3 0 0 0-.54-.24a6.8 6.8 0 0 0-.2 2.33c-1.281-.38-1.121.13-1.131-.42a15 15 0 0 0-.19-1.93c-.16-.17-.36-.17-.51.14a20 20 0 0 0-.43 3.471c.04.773.18 1.536.42 2.272c.26.4.7.22.7-.1c0-.09-.16-.09 0-1.862c.06-1.18-.23-.3 1.16-.76m5.033-2.552c.32-.07.41-.28.39-.37c0-.55-3.322-.34-3.462-.24s-.2.18-.18.28s0 .11 0 .16a3.8 3.8 0 0 0 1.591.361v.82a15 15 0 0 0-.13 3.132c0 .2-.09.94.17 1.16a.34.34 0 0 0 .48 0c.125-.35.196-.718.21-1.09a8 8 0 0 0 .14-3.232c0-.13.05-.7-.1-.89a8 8 0 0 0 .89-.09m5.544-.181a.69.69 0 0 0-.89-.44a2.8 2.8 0 0 0-1.252 1.001a2.3 2.3 0 0 0-.41-.83a1 1 0 0 0-1.6.27a7 7 0 0 0-.35 2.07c0 .571 0 2.642.06 2.762c.14 1.09 1 .51.63.13a17.6 17.6 0 0 1 .38-3.962c.32-1.18.32.2.39.51s.11 1.081.73 1.081s.48-.93 1.401-1.78q.075 1.345 0 2.69a15 15 0 0 0 0 1.811a.34.34 0 0 0 .68 0q.112-.861.11-1.73a16.7 16.7 0 0 0 .12-3.582m1.441-.201c-.05.16-.3 3.002-.31 3.202a6.3 6.3 0 0 0 .21 1.741c.33 1 1.21 1.07 2.291.82a3.7 3.7 0 0 0 1.14-.23c.21-.22.10-.59-.41-.64q-.817.096-1.64.07c-.44-.07-.34 0-.67-4.442q.015-.185 0-.37a.316.316 0 0 0-.23-.38a.316.316 0 0 0-.38.23" />
</g>
</svg>
</button>
</div>
</div>
<div
class="code-block-body"
[class.code-block-body--collapsed]="collapsed"
[class.code-block-body--expanded]="expanded"
>
<ng-container *ngIf="!showLoadingPlaceholder; else loadingTpl">
<markstream-angular-pre-code-node *ngIf="useFallback; else editorTpl" [node]="node" />
<ng-template #editorTpl>
<div #editorHost class="code-editor-container" [style.visibility]="editorReady ? 'visible' : 'hidden'"></div>
<div *ngIf="!editorReady" style="position:absolute; inset:0; overflow:auto; padding:1rem;">
<pre class="code-fallback-plain m-0" [attr.aria-busy]="resolvedLoading" [attr.aria-label]="ariaLabel" tabindex="0"><code translate="no" [style.fontSize.px]="fontSize" [textContent]="resolvedCode"></code></pre>
</div>
</ng-template>
</ng-container>
</div>
<markstream-angular-html-preview-frame
*ngIf="inlinePreviewOpen && isPreviewable"
[code]="resolvedCode"
[isDark]="resolvedIsDark"
[title]="previewTitle"
[onClose]="closeInlinePreview"
/>
<span class="sr-only" aria-live="polite" role="status">{{ copied ? t('common.copied') : '' }}</span>
</div>
<ng-template #loadingTpl>
<div class="code-loading-placeholder">
<div class="loading-skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</div>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CodeBlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
private readonly i18n = useSafeI18n()
@ViewChild('editorHost') private editorHost?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() props?: Record<string, any>
useFallback = false
copied = false
collapsed = false
expanded = false
inlinePreviewOpen = false
editorReady = false
defaultFontSize = 14
fontSize = 14
private helpers: MonacoHelpers | null = null
private createPromise: Promise<void> | null = null
private syncPromise: Promise<void> | null = null
private editorKind: 'single' | 'diff' | null = null
private viewReady = false
private destroyed = false
private copyTimer: number | null = null
private deferredHeightSyncRaf: number | null = null
get t() {
return this.i18n.t
}
get mergedProps() {
return {
...(this.context?.codeBlockProps || {}),
...(this.props || {}),
}
}
get resolvedLoading() {
const explicit = this.mergedProps.loading
if (typeof explicit === 'boolean')
return explicit
return (this.node as any)?.loading !== false
}
get resolvedStream() {
const explicit = this.mergedProps.stream
if (typeof explicit === 'boolean')
return explicit
if (typeof this.context?.codeBlockStream === 'boolean')
return this.context.codeBlockStream
return true
}
get resolvedIsDark() {
const explicit = this.mergedProps.isDark
if (typeof explicit === 'boolean')
return explicit
return this.context?.isDark === true
}
get resolvedDarkTheme() {
return this.mergedProps.darkTheme ?? this.context?.codeBlockThemes?.darkTheme ?? 'vitesse-dark'
}
get resolvedLightTheme() {
return this.mergedProps.lightTheme ?? this.context?.codeBlockThemes?.lightTheme ?? 'vitesse-light'
}
get resolvedThemes() {
return this.mergedProps.themes ?? this.context?.codeBlockThemes?.themes ?? ['vitesse-dark', 'vitesse-light']
}
get resolvedMonacoOptions() {
return {
...(this.context?.codeBlockThemes?.monacoOptions || {}),
...(this.mergedProps.monacoOptions || {}),
}
}
get resolvedEnableFontSizeControl() {
if (typeof this.mergedProps.enableFontSizeControl === 'boolean')
return this.mergedProps.enableFontSizeControl
return true
}
get resolvedShowHeader() {
return this.mergedProps.showHeader !== false
}
get resolvedShowCopyButton() {
return this.mergedProps.showCopyButton !== false
}
get resolvedShowExpandButton() {
return this.mergedProps.showExpandButton !== false
}
get resolvedShowPreviewButton() {
return this.mergedProps.showPreviewButton !== false
}
get resolvedShowCollapseButton() {
return this.mergedProps.showCollapseButton !== false
}
get resolvedShowFontSizeButtons() {
return this.mergedProps.showFontSizeButtons !== false
}
get resolvedShowTooltips() {
if (typeof this.mergedProps.showTooltips === 'boolean')
return this.mergedProps.showTooltips
return this.context?.showTooltips !== false
}
get resolvedIsShowPreview() {
if (typeof this.mergedProps.isShowPreview === 'boolean')
return this.mergedProps.isShowPreview
return true
}
get rawLanguage() {
return getString((this.node as any)?.language || 'plaintext')
}
get canonicalLanguage() {
return normalizeLanguageIdentifier(this.rawLanguage) || 'plain'
}
get monacoLanguage() {
return resolveMonacoLanguageId(this.rawLanguage)
}
get isPlainTextLanguage() {
return this.monacoLanguage === 'plaintext'
}
get displayLanguage() {
const label = languageMap[this.canonicalLanguage] || this.canonicalLanguage
return label ? label.charAt(0).toUpperCase() + label.slice(1) : 'Text'
}
get languageIconDataUrl() {
const icon = getLanguageIcon(this.rawLanguage).trim()
const svg = icon.includes('xmlns=')
? icon
: icon.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"')
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
get resolvedCode() {
if (this.isDiff)
return getString((this.node as any)?.updatedCode || (this.node as any)?.code)
return getString((this.node as any)?.code)
}
get originalCode() {
return getString((this.node as any)?.originalCode)
}
get isDiff() {
return !!(this.node as any)?.diff
}
get isPreviewable() {
if (!this.resolvedIsShowPreview)
return false
return this.canonicalLanguage === 'html' || this.canonicalLanguage === 'svg'
}
get previewTitle() {
return this.canonicalLanguage === 'html'
? this.t('artifacts.htmlPreviewTitle')
: this.t('artifacts.svgPreviewTitle')
}
get headerForeground() {
return `var(--vscode-editor-foreground, ${this.resolvedIsDark ? '#e5e7eb' : '#111827'})`
}
get headerBackground() {
return `var(--vscode-editor-background, ${this.resolvedIsDark ? '#111827' : '#ffffff'})`
}
get ariaLabel() {
return `Code block: ${this.displayLanguage}`
}
get showLoadingPlaceholder() {
return this.shouldDelayEditor
}
get shouldDelayEditor() {
return !this.resolvedStream && this.resolvedLoading
}
get containerStyle() {
const style: Record<string, string> = {}
const min = this.resolveCssSize(this.mergedProps.minWidth ?? this.context?.codeBlockThemes?.minWidth)
const max = this.resolveCssSize(this.mergedProps.maxWidth ?? this.context?.codeBlockThemes?.maxWidth)
if (min)
style.minWidth = min
if (max)
style.maxWidth = max
return style
}
readonly closeInlinePreview = () => {
this.inlinePreviewOpen = false
this.cdr.markForCheck()
}
ngAfterViewInit() {
this.viewReady = true
this.applyInitialFontSize()
void this.syncEditorState()
}
ngOnChanges() {
this.applyInitialFontSize()
if (!this.viewReady)
return
void this.syncEditorState()
}
ngOnDestroy() {
this.destroyed = true
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
this.cancelDeferredHeightSync()
this.cleanupEditor()
}
tooltipFor(text: string) {
return this.resolvedShowTooltips ? text : null
}
toggleCollapsed() {
this.collapsed = !this.collapsed
this.inlinePreviewOpen = false
if (!this.collapsed)
queueMicrotask(() => void this.syncEditorState())
this.cdr.markForCheck()
}
toggleExpanded() {
this.expanded = !this.expanded
queueMicrotask(() => this.applyEditorHeight())
this.cdr.markForCheck()
}
changeFontSize(delta: number) {
this.fontSize = Math.min(36, Math.max(10, this.fontSize + delta))
this.applyEditorFontSize()
this.applyEditorHeight()
this.cdr.markForCheck()
}
resetFontSize() {
this.fontSize = this.defaultFontSize
this.applyEditorFontSize()
this.applyEditorHeight()
this.cdr.markForCheck()
}
async copyCode() {
const text = this.resolvedCode
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText)
await navigator.clipboard.writeText(text)
else
this.copyUsingTextarea(text)
}
catch {
this.copyUsingTextarea(text)
}
this.copied = true
this.context?.events.onCopy?.(text)
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
if (typeof window !== 'undefined') {
this.copyTimer = window.setTimeout(() => {
this.copied = false
this.cdr.markForCheck()
}, 1000)
}
this.cdr.markForCheck()
}
previewCode() {
if (!this.isPreviewable)
return
const artifactType = this.canonicalLanguage === 'html' ? 'text/html' : 'image/svg+xml'
const artifactTitle = this.previewTitle
if (typeof this.context?.events.onHandleArtifactClick === 'function') {
this.context.events.onHandleArtifactClick({
node: this.node,
artifactType,
artifactTitle,
id: `temp-${this.canonicalLanguage}-${Date.now()}`,
})
return
}
if (this.canonicalLanguage === 'html')
this.inlinePreviewOpen = !this.inlinePreviewOpen
this.cdr.markForCheck()
}
private applyInitialFontSize() {
const initial = Number(this.resolvedMonacoOptions.fontSize)
this.defaultFontSize = Number.isFinite(initial) && initial > 0 ? initial : 14
if (!(typeof this.fontSize === 'number' && Number.isFinite(this.fontSize) && this.fontSize > 0))
this.fontSize = this.defaultFontSize
if (this.fontSize < 10 || this.fontSize > 36)
this.fontSize = this.defaultFontSize
}
private async syncEditorState() {
if (this.destroyed || !this.viewReady)
return
if (this.collapsed || this.showLoadingPlaceholder) {
this.editorReady = false
this.cdr.markForCheck()
return
}
await this.ensureHelpers()
if (this.destroyed || this.useFallback || !this.helpers) {
this.cdr.markForCheck()
return
}
if (!this.editorHost?.nativeElement) {
this.editorReady = false
this.cdr.markForCheck()
return
}
if (this.syncPromise)
return this.syncPromise
this.syncPromise = (async () => {
try {
const desiredKind = this.isDiff && typeof this.helpers?.createDiffEditor === 'function' ? 'diff' : 'single'
if (this.editorKind !== desiredKind) {
await this.recreateEditor(desiredKind)
}
else {
await this.updateEditor()
}
await Promise.resolve(this.helpers?.setTheme?.(this.resolvedIsDark ? this.resolvedDarkTheme : this.resolvedLightTheme))
this.applyEditorFontSize()
if (this.expanded || !this.resolvedLoading || !this.editorReady)
this.applyEditorHeight()
this.editorReady = true
this.scheduleDeferredHeightSync()
}
catch {
this.useFallback = true
this.editorReady = false
this.cleanupEditor()
}
finally {
this.syncPromise = null
this.cdr.markForCheck()
}
})()
return this.syncPromise
}
private async ensureHelpers() {
if (this.helpers || this.useFallback)
return
if (this.createPromise)
return this.createPromise
this.createPromise = (async () => {
const monacoModule = await getUseMonaco()
if (!monacoModule || typeof monacoModule.useMonaco !== 'function') {
this.useFallback = true
return
}
const options = {
wordWrap: 'on',
wrappingIndent: 'same',
readOnly: true,
minimap: { enabled: false },
lineNumbers: 'on',
revealDebounceMs: 75,
MAX_HEIGHT: 500,
fontSize: this.defaultFontSize,
themes: this.resolvedThemes,
theme: this.resolvedIsDark ? this.resolvedDarkTheme : this.resolvedLightTheme,
...(this.resolvedMonacoOptions || {}),
}
this.helpers = monacoModule.useMonaco(options)
})()
try {
await this.createPromise
}
finally {
this.createPromise = null
}
}
private async recreateEditor(kind: 'single' | 'diff') {
const host = this.editorHost?.nativeElement
if (!host || !this.helpers)
return
this.cleanupEditor()
host.innerHTML = ''
this.editorKind = kind
if (kind === 'diff') {
await Promise.resolve(this.helpers.createDiffEditor?.(
host,
this.originalCode,
this.resolvedCode,
this.monacoLanguage,
))
return
}
await Promise.resolve(this.helpers.createEditor?.(
host,
this.resolvedCode,
this.monacoLanguage,
))
}
private async updateEditor() {
if (!this.helpers)
return
if (this.isDiff && this.editorKind === 'diff' && typeof this.helpers.updateDiff === 'function') {
await Promise.resolve(this.helpers.updateDiff(this.originalCode, this.resolvedCode, this.monacoLanguage))
return
}
await Promise.resolve(this.helpers.updateCode?.(this.resolvedCode, this.monacoLanguage))
}
private applyEditorFontSize() {
const view = this.editorKind === 'diff'
? this.helpers?.getDiffEditorView?.()
: this.helpers?.getEditorView?.()
try {
view?.updateOptions?.({ fontSize: this.fontSize, automaticLayout: this.expanded })
if (this.editorKind === 'diff' && typeof view?.getModifiedEditor === 'function')
view.getModifiedEditor()?.updateOptions?.({ fontSize: this.fontSize })
}
catch {}
}
private applyEditorHeight() {
const host = this.editorHost?.nativeElement
if (!host)
return
const maxHeight = this.resolveMaxHeight()
host.style.minHeight = '0px'
host.style.maxHeight = this.expanded ? 'none' : `${maxHeight}px`
host.style.overflow = this.expanded ? 'visible' : 'auto'
const view = this.editorKind === 'diff'
? this.helpers?.getDiffEditorView?.()
: this.helpers?.getEditorView?.()
if (!view)
return
try {
view.updateOptions?.({ automaticLayout: this.expanded })
}
catch {}
const height = this.resolveEditorHeight(view, maxHeight)
host.style.height = `${height}px`
try {
view.layout?.()
}
catch {}
}
private resolveEditorHeight(view: any, maxHeight: number) {
let contentHeight = Number.NaN
try {
if (typeof view.getContentHeight === 'function')
contentHeight = Number(view.getContentHeight())
else if (this.editorKind === 'diff' && typeof view.getModifiedEditor === 'function')
contentHeight = Number(view.getModifiedEditor()?.getContentHeight?.())
}
catch {}
if (!Number.isFinite(contentHeight) || contentHeight <= 0) {
const lines = this.resolveEditorLineCount(view)
const lineHeight = this.resolveEditorLineHeight()
const estimate = lines * (lineHeight + 1.5) + 1
return Math.ceil(Math.max(1, this.expanded ? estimate : Math.min(estimate, maxHeight)))
}
return Math.ceil(Math.max(1, this.expanded ? contentHeight : Math.min(contentHeight, maxHeight)))
}
private resolveEditorLineCount(view: any) {
try {
if (
this.editorKind === 'diff'
&& typeof view?.getOriginalEditor === 'function'
&& typeof view?.getModifiedEditor === 'function'
) {
const originalLines = Number(view.getOriginalEditor?.()?.getModel?.()?.getLineCount?.() ?? 1)
const modifiedLines = Number(view.getModifiedEditor?.()?.getModel?.()?.getLineCount?.() ?? 1)
return Math.max(1, originalLines, modifiedLines)
}
const lines = Number(view?.getModel?.()?.getLineCount?.() ?? 1)
if (Number.isFinite(lines) && lines > 0)
return lines
}
catch {}
return Math.max(1, String(this.resolvedCode || '').split('\n').length)
}
private resolveEditorLineHeight() {
const fromOptions = Number(this.resolvedMonacoOptions.lineHeight)
if (Number.isFinite(fromOptions) && fromOptions > 0)
return fromOptions
const fromFontOption = Number(this.resolvedMonacoOptions.fontSize)
if (Number.isFinite(fromFontOption) && fromFontOption > 0)
return Math.max(12, Math.round(fromFontOption * 1.35))
const fromState = Number(this.fontSize)
if (Number.isFinite(fromState) && fromState > 0)
return Math.max(12, Math.round(fromState * 1.35))
return 18
}
private resolveMaxHeight() {
const raw = this.resolvedMonacoOptions.MAX_HEIGHT ?? 500
if (typeof raw === 'number' && Number.isFinite(raw))
return raw > 0 ? raw : 500
const matched = String(raw).match(/^(\d+(?:\.\d+)?)/)
const parsed = matched ? Number.parseFloat(matched[1]) : 500
return Number.isFinite(parsed) && parsed > 0 ? parsed : 500
}
private cancelDeferredHeightSync() {
if (this.deferredHeightSyncRaf == null || typeof window === 'undefined')
return
window.cancelAnimationFrame(this.deferredHeightSyncRaf)
this.deferredHeightSyncRaf = null
}
private scheduleDeferredHeightSync() {
if (typeof window === 'undefined' || this.destroyed)
return
this.cancelDeferredHeightSync()
this.deferredHeightSyncRaf = window.requestAnimationFrame(() => {
this.deferredHeightSyncRaf = window.requestAnimationFrame(() => {
this.deferredHeightSyncRaf = null
this.applyEditorHeight()
})
})
}
private cleanupEditor() {
try {
this.helpers?.safeClean?.()
}
catch {}
try {
this.helpers?.cleanupEditor?.()
}
catch {}
this.editorKind = null
}
private resolveCssSize(value: unknown) {
if (value == null || value === '')
return null
return typeof value === 'number' ? `${value}px` : String(value)
}
private copyUsingTextarea(text: string) {
if (typeof document === 'undefined')
return
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
}
catch {}
finally {
textarea.remove()
}
}
}
@@ -0,0 +1,87 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { useSafeI18n } from '../../i18n/useSafeI18n'
@Component({
selector: 'markstream-angular-html-preview-frame',
standalone: true,
imports: [CommonModule],
template: `
<div
class="html-preview-frame__backdrop"
[class.html-preview-frame__backdrop--dark]="isDark"
(click)="onClose?.()"
>
<div
class="html-preview-frame"
[class.html-preview-frame--dark]="isDark"
(click)="$event.stopPropagation()"
>
<div class="html-preview-frame__header">
<div class="html-preview-frame__title">
<span class="html-preview-frame__dot"></span>
<span class="html-preview-frame__label">{{ resolvedTitle }}</span>
</div>
<button
type="button"
class="html-preview-frame__close"
[class.html-preview-frame__close--dark]="isDark"
(click)="onClose?.()"
>
×
</button>
</div>
<iframe
class="html-preview-frame__iframe"
sandbox="allow-scripts allow-same-origin"
[srcdoc]="srcdoc"
[title]="resolvedTitle"
></iframe>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HtmlPreviewFrameComponent {
@Input() code = ''
@Input() isDark = false
@Input() title?: string
@Input() onClose?: () => void
private readonly i18n = useSafeI18n()
get resolvedTitle() {
return this.title || `HTML ${this.i18n.t('common.preview')}`
}
get srcdoc() {
const base = this.code || ''
const lowered = base.trim().toLowerCase()
if (lowered.startsWith('<!doctype') || lowered.startsWith('<html') || lowered.startsWith('<body'))
return base
const bg = this.isDark ? '#020617' : '#ffffff'
const fg = this.isDark ? '#e5e7eb' : '#020617'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background-color: ${bg};
color: ${fg};
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', ui-sans-serif, sans-serif;
}
</style>
</head>
<body>
${base}
</body>
</html>`
}
}
@@ -0,0 +1,383 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getD2 } from '../../optional/d2'
import { extractRenderedSvg, toSafeSvgMarkup } from '../../sanitizeSvg'
import { getString } from '../shared/node-helpers'
import {
copyTextToClipboard,
downloadSvgMarkup,
resolveCssSize,
setElementHtml,
} from '../shared/rich-block-helpers'
const DARK_THEME_OVERRIDES: Record<string, string> = {
N1: '#E5E7EB',
N2: '#CBD5E1',
N3: '#94A3B8',
N4: '#64748B',
N5: '#475569',
N6: '#334155',
N7: '#0B1220',
B1: '#60A5FA',
B2: '#3B82F6',
B3: '#2563EB',
B4: '#1D4ED8',
B5: '#1E40AF',
B6: '#111827',
AA2: '#22D3EE',
AA4: '#0EA5E9',
AA5: '#0284C7',
AB4: '#FBBF24',
AB5: '#F59E0B',
}
@Component({
selector: 'markstream-angular-d2-block-node',
standalone: true,
imports: [CommonModule],
template: `
<div
class="markstream-angular-enhanced-block markstream-angular-enhanced-block--d2"
[attr.data-markstream-d2]="'1'"
[attr.data-mode]="showSource ? 'source' : 'preview'"
>
<div *ngIf="resolvedShowHeader" class="markstream-angular-enhanced-block__header">
<div class="flex items-center gap-2 min-w-0">
<span class="markstream-angular-enhanced-block__badge">D2</span>
<span class="mermaid-title__text truncate">Diagram</span>
</div>
<div class="markstream-angular-enhanced-block__actions">
<button
*ngIf="resolvedShowModeToggle"
type="button"
class="markstream-angular-enhanced-block__action mode-btn"
[class.is-active]="!showSource"
(click)="setMode(false)"
>
Preview
</button>
<button
*ngIf="resolvedShowModeToggle"
type="button"
class="markstream-angular-enhanced-block__action mode-btn"
[class.is-active]="showSource"
(click)="setMode(true)"
>
Source
</button>
<button
*ngIf="resolvedShowCopyButton"
type="button"
class="markstream-angular-enhanced-block__action d2-action-btn"
[attr.title]="tooltipFor(copied ? 'Copied' : 'Copy')"
(click)="copySource()"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button
*ngIf="resolvedShowExportButton"
type="button"
class="markstream-angular-enhanced-block__action d2-action-btn"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Export SVG')"
(click)="exportSvg()"
>
Export
</button>
<button
*ngIf="resolvedShowCollapseButton"
type="button"
class="markstream-angular-enhanced-block__action d2-action-btn"
[attr.title]="tooltipFor(collapsed ? 'Expand' : 'Collapse')"
(click)="toggleCollapsed()"
>
{{ collapsed ? 'Expand' : 'Collapse' }}
</button>
</div>
</div>
<div *ngIf="!collapsed" class="markstream-angular-enhanced-block__body d2-block-body" [style.maxHeight]="resolvedMaxHeight">
<div *ngIf="rendering && !showSource" class="mermaid-loading">
<span class="mermaid-spinner" aria-hidden="true"></span>
<span>Rendering D2...</span>
</div>
<div *ngIf="!showSource" class="d2-render">
<div #previewHost class="d2-svg"></div>
</div>
<div *ngIf="showSource" class="d2-source">
<pre class="markstream-angular-enhanced-block__source d2-code"><code translate="no">{{ code }}</code></pre>
</div>
<div *ngIf="error" class="d2-error">{{ error }}</div>
</div>
<span class="sr-only" aria-live="polite" role="status">{{ copied ? 'Copied' : '' }}</span>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class D2BlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('previewHost') private previewHost?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() props?: Record<string, any>
rendering = false
copied = false
collapsed = false
showSource = false
svgMarkup = ''
error = ''
private viewReady = false
private destroyed = false
private renderToken = 0
private copyTimer: number | null = null
get mergedProps() {
return {
...(this.context?.d2Props || {}),
...(this.props || {}),
}
}
get code() {
return getString((this.node as any)?.code)
}
get resolvedIsDark() {
if (typeof this.mergedProps.isDark === 'boolean')
return this.mergedProps.isDark
return this.context?.isDark === true
}
get resolvedShowHeader() {
return this.mergedProps.showHeader !== false
}
get resolvedShowModeToggle() {
return this.mergedProps.showModeToggle !== false
}
get resolvedShowCopyButton() {
return this.mergedProps.showCopyButton !== false
}
get resolvedShowExportButton() {
return this.mergedProps.showExportButton !== false
}
get resolvedShowCollapseButton() {
return this.mergedProps.showCollapseButton !== false
}
get resolvedShowTooltips() {
if (typeof this.mergedProps.showTooltips === 'boolean')
return this.mergedProps.showTooltips
return this.context?.showTooltips !== false
}
get resolvedMaxHeight() {
return resolveCssSize(this.mergedProps.maxHeight, '500px')
}
get resolvedLoading() {
if (typeof this.mergedProps.loading === 'boolean')
return this.mergedProps.loading
return (this.node as any)?.loading === true
}
get resolvedProgressiveRender() {
return this.mergedProps.progressiveRender === true
}
ngAfterViewInit() {
this.viewReady = true
queueMicrotask(() => void this.renderDiagram())
}
ngOnChanges() {
if (!this.viewReady)
return
queueMicrotask(() => void this.renderDiagram())
}
ngOnDestroy() {
this.destroyed = true
this.renderToken += 1
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
}
tooltipFor(text: string) {
return this.resolvedShowTooltips ? text : null
}
setMode(nextSource: boolean) {
this.showSource = nextSource
if (!nextSource)
this.scheduleHostSync()
this.cdr.markForCheck()
}
async copySource() {
await copyTextToClipboard(this.code)
this.copied = true
this.context?.events.onCopy?.(this.code)
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
if (typeof window !== 'undefined') {
this.copyTimer = window.setTimeout(() => {
this.copied = false
this.cdr.markForCheck()
}, 1000)
}
this.cdr.markForCheck()
}
exportSvg() {
if (!this.svgMarkup)
return
downloadSvgMarkup(this.svgMarkup, `d2-${Date.now()}.svg`)
}
toggleCollapsed() {
this.collapsed = !this.collapsed
if (!this.collapsed)
queueMicrotask(() => void this.renderDiagram())
this.cdr.markForCheck()
}
private async renderDiagram() {
if (this.destroyed || !this.viewReady || this.collapsed)
return
const source = this.code.trim()
if (!source) {
this.svgMarkup = ''
this.error = ''
this.syncSvgHost()
this.cdr.markForCheck()
return
}
if (this.resolvedLoading && !this.resolvedProgressiveRender) {
this.renderToken += 1
this.rendering = true
this.error = ''
this.svgMarkup = ''
this.syncSvgHost()
this.cdr.markForCheck()
return
}
const token = ++this.renderToken
this.rendering = true
this.error = ''
this.cdr.markForCheck()
try {
const D2Ctor = await getD2()
if (!D2Ctor)
throw new Error('D2 renderer is not available.')
const instance = createD2Instance(D2Ctor)
if (!instance || typeof instance.compile !== 'function' || typeof instance.render !== 'function')
throw new TypeError('D2 instance is missing compile/render methods.')
const compileResult = await instance.compile(source)
const diagram = compileResult?.diagram ?? compileResult
const baseRenderOptions = compileResult?.renderOptions ?? compileResult?.options ?? {}
const renderOptions: Record<string, any> = {
...baseRenderOptions,
themeID: this.resolvedIsDark
? (this.mergedProps.darkThemeId ?? baseRenderOptions.darkThemeID ?? baseRenderOptions.themeID)
: (this.mergedProps.themeId ?? baseRenderOptions.themeID),
darkThemeID: null,
darkThemeOverrides: null,
}
if (this.resolvedIsDark) {
renderOptions.themeOverrides = {
...(baseRenderOptions.themeOverrides || {}),
...DARK_THEME_OVERRIDES,
}
}
const renderResult = await instance.render(diagram, renderOptions)
if (this.destroyed || token !== this.renderToken)
return
const safeSvg = toSafeSvgMarkup(extractRenderedSvg(renderResult))
if (!safeSvg)
throw new Error('D2 render returned empty output.')
this.svgMarkup = safeSvg
this.syncSvgHost()
}
catch (error) {
if (this.destroyed || token !== this.renderToken)
return
this.svgMarkup = ''
this.showSource = true
this.error = error instanceof Error ? error.message : 'Failed to render D2 diagram.'
this.syncSvgHost()
}
finally {
if (token === this.renderToken) {
this.rendering = false
this.cdr.markForCheck()
}
}
}
private syncSvgHost() {
setElementHtml(this.previewHost?.nativeElement, this.svgMarkup)
}
private scheduleHostSync() {
if (typeof window !== 'undefined') {
window.setTimeout(() => {
if (this.destroyed)
return
this.syncSvgHost()
this.cdr.markForCheck()
}, 0)
return
}
queueMicrotask(() => this.syncSvgHost())
}
}
function createD2Instance(D2Ctor: any) {
if (typeof D2Ctor === 'function') {
const instance = new D2Ctor()
if (instance && typeof instance.compile === 'function')
return instance
if (typeof D2Ctor.compile === 'function')
return D2Ctor
}
if (D2Ctor?.D2 && typeof D2Ctor.D2 === 'function')
return new D2Ctor.D2()
if (typeof D2Ctor?.compile === 'function')
return D2Ctor
return null
}
@@ -0,0 +1,57 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-definition-list-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<dl>
<ng-container *ngFor="let item of items; let idx = index; trackBy: trackByIndex">
<dt>
<markstream-angular-nested-renderer
[nodes]="termFor(item)"
[context]="context"
[indexPrefix]="itemPrefix(idx, 'term')"
/>
</dt>
<dd>
<markstream-angular-nested-renderer
[nodes]="definitionFor(item)"
[context]="context"
[indexPrefix]="itemPrefix(idx, 'definition')"
/>
</dd>
</ng-container>
</dl>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DefinitionListNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get items() {
return getNodeList((this.node as any)?.items)
}
termFor(item: AngularRenderableNode) {
return getNodeList((item as any)?.term)
}
definitionFor(item: AngularRenderableNode) {
return getNodeList((item as any)?.definition)
}
trackByIndex = (index: number) => {
return `${this.indexKey || 'definition-list'}-${index}`
}
itemPrefix(index: number, part: string) {
return `${this.indexKey || 'definition-list'}-${index}-${part}`
}
}
@@ -0,0 +1,114 @@
import type { ComponentRef, OnChanges, OnDestroy, SimpleChanges, Type } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import {
ChangeDetectionStrategy,
Component,
Input,
ViewChild,
ViewContainerRef,
} from '@angular/core'
@Component({
selector: 'markstream-angular-dynamic-node-host',
standalone: true,
template: '<ng-template #container></ng-template>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DynamicNodeHostComponent implements OnChanges, OnDestroy {
@Input() component?: Type<any> | null
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
@Input() inputs?: Record<string, unknown> | null
@ViewChild('container', { read: ViewContainerRef, static: true })
private containerRef?: ViewContainerRef
private componentRef: ComponentRef<any> | null = null
ngOnChanges(changes: SimpleChanges) {
if (!this.containerRef)
return
if (changes.component)
this.mountComponent()
this.applyInputs()
}
ngOnDestroy() {
this.componentRef?.destroy()
this.componentRef = null
}
private mountComponent() {
this.containerRef?.clear()
this.componentRef?.destroy()
this.componentRef = null
if (!this.component || !this.containerRef)
return
this.componentRef = this.containerRef.createComponent(this.component)
}
private applyInputs() {
const componentRef = this.componentRef
const instance = componentRef?.instance
if (!instance || !componentRef)
return
const setValue = (key: string, value: unknown) => {
if (this.hasDeclaredInput(componentRef, key)) {
try {
componentRef.setInput(key, value)
}
catch {
// Fall back to direct instance assignment for edge-case compiled components.
}
}
instance[key] = value
}
setValue('node', this.node)
setValue('nodes', Array.isArray((this.node as any)?.children) ? (this.node as any).children : undefined)
setValue('content', typeof (this.node as any)?.content === 'string' ? (this.node as any).content : undefined)
setValue('loading', (this.node as any)?.loading)
setValue('final', typeof (this.node as any)?.loading === 'boolean' ? !(this.node as any).loading : this.context?.final)
setValue('context', this.context)
setValue('ctx', this.context)
setValue('customId', this.context?.customId)
setValue('customHtmlTags', this.context?.customHtmlTags)
setValue('parseOptions', this.context?.parseOptions)
setValue('customMarkdownIt', this.context?.customMarkdownIt)
setValue('isDark', this.context?.isDark)
setValue('indexKey', this.indexKey)
setValue('typewriter', this.context?.typewriter)
setValue('showTooltips', this.context?.showTooltips)
setValue('codeBlockStream', this.context?.codeBlockStream)
setValue('renderCodeBlocksAsPre', this.context?.renderCodeBlocksAsPre)
setValue('codeBlockProps', this.context?.codeBlockProps)
setValue('mermaidProps', this.context?.mermaidProps)
setValue('d2Props', this.context?.d2Props)
setValue('infographicProps', this.context?.infographicProps)
if (this.inputs) {
for (const [key, value] of Object.entries(this.inputs))
setValue(key, value)
}
componentRef.changeDetectorRef.detectChanges()
}
private hasDeclaredInput(componentRef: ComponentRef<any>, key: string) {
const inputMap = (componentRef.componentType as any)?.ɵcmp?.inputs
if (!inputMap || typeof inputMap !== 'object')
return false
if (key in inputMap)
return true
return Object.values(inputMap).includes(key)
}
}
@@ -0,0 +1,17 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-emoji-node',
standalone: true,
template: '<span class="markstream-angular-emoji">{{ text }}</span>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmojiNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get text() {
return getString((this.node as any)?.raw || (this.node as any)?.markup || (this.node as any)?.content || (this.node as any)?.name)
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-emphasis-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<em>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</em>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmphasisNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'emphasis'}-em`
}
}
@@ -0,0 +1,79 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import {
escapeHtml,
getNodeList,
getString,
sanitizeClassToken,
} from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-fallback-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<div
class="markstream-nested-custom"
[class]="customClassName"
[attr.data-markstream-custom-tag]="customTag"
>
<markstream-angular-nested-renderer
*ngIf="hasNestedContent; else fallbackBody"
[node]="node"
[context]="context"
[indexPrefix]="nestedPrefix"
[final]="resolvedFinal"
[customHtmlTags]="context?.customHtmlTags"
[parseOptions]="context?.parseOptions"
[customMarkdownIt]="context?.customMarkdownIt"
/>
<ng-template #fallbackBody>
<div [innerHTML]="rawBodyHtml"></div>
</ng-template>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FallbackComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get hasChildren() {
return this.children.length > 0
}
get hasNestedContent() {
return this.hasChildren || !!getString((this.node as any)?.content)
}
get customTag() {
return getString((this.node as any)?.tag || (this.node as any)?.type)
}
get customClassName() {
const tag = sanitizeClassToken(this.customTag) || 'node'
return `markstream-nested-custom--${tag}`
}
get rawBodyHtml() {
return escapeHtml(getString((this.node as any)?.raw))
}
get nestedPrefix() {
return `${this.indexKey || 'fallback'}-children`
}
get resolvedFinal() {
if (typeof (this.node as any)?.loading === 'boolean')
return !(this.node as any).loading
return this.context?.final
}
}
@@ -0,0 +1,33 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-footnote-anchor-node',
standalone: true,
template: `
<a
class="footnote-anchor text-sm text-[#0366d6] hover:underline cursor-pointer"
[attr.href]="href"
[attr.title]="title"
>
↩︎
</a>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FootnoteAnchorNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get id() {
return getString((this.node as any)?.id)
}
get href() {
return `#fnref-${this.id}`
}
get title() {
return `Back to reference ${this.id}`
}
}
@@ -0,0 +1,38 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList, getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-footnote-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<div [attr.id]="footnoteId" class="markstream-nested-footnote">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FootnoteNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get footnoteId() {
return `fnref--${getString((this.node as any)?.id)}`
}
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'footnote'}-children`
}
}
@@ -0,0 +1,25 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-footnote-reference-node',
standalone: true,
template: `
<sup class="markstream-nested-footnote-ref">
<a [attr.href]="href">[{{ id }}]</a>
</sup>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FootnoteReferenceNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get id() {
return getString((this.node as any)?.id)
}
get href() {
return `#fnref--${this.id}`
}
}
@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'markstream-angular-hardbreak-node',
standalone: true,
template: '<br>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HardBreakNodeComponent {}
@@ -0,0 +1,55 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { clampHeadingLevel, getNodeList } from '../shared/node-helpers'
import { SafeAttrsDirective } from '../shared/safe-attrs.directive'
@Component({
selector: 'markstream-angular-heading-node',
standalone: true,
imports: [CommonModule, SafeAttrsDirective, forwardRef(() => NestedRendererComponent)],
template: `
<h1 *ngIf="level === 1" dir="auto" class="heading-node heading-1" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h1>
<h2 *ngIf="level === 2" dir="auto" class="heading-node heading-2" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h2>
<h3 *ngIf="level === 3" dir="auto" class="heading-node heading-3" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h3>
<h4 *ngIf="level === 4" dir="auto" class="heading-node heading-4" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h4>
<h5 *ngIf="level === 5" dir="auto" class="heading-node heading-5" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h5>
<h6 *ngIf="level === 6" dir="auto" class="heading-node heading-6" [markstreamSafeAttrs]="attrs">
<markstream-angular-nested-renderer [nodes]="children" [context]="context" [indexPrefix]="nestedPrefix" />
</h6>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeadingNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get level() {
return clampHeadingLevel((this.node as any)?.level)
}
get attrs() {
const raw = (this.node as any)?.attrs
return raw && typeof raw === 'object' ? raw : null
}
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'heading'}-${this.level}`
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-highlight-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<mark>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</mark>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HighlightNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'highlight'}-highlight`
}
}
@@ -0,0 +1,62 @@
import type { ElementRef } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input, ViewChild } from '@angular/core'
import { NON_STRUCTURING_HTML_TAGS } from 'stream-markdown-parser'
import { renderMarkdownNodeToHtml } from '../../renderMarkdownHtml'
import { sanitizeHtmlContent } from '../../sanitizeHtmlContent'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-html-block-node',
standalone: true,
template: '<div #containerRef class="html-block-node"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HtmlBlockNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@ViewChild('containerRef', { static: true }) private readonly containerRef?: ElementRef<HTMLElement>
ngAfterViewInit() {
this.commitContent()
}
ngOnChanges() {
this.commitContent()
}
ngOnDestroy() {
const container = this.containerRef?.nativeElement
if (container)
container.innerHTML = ''
}
private commitContent() {
const container = this.containerRef?.nativeElement
if (!container)
return
const content = getString((this.node as any)?.content)
const tag = getString((this.node as any)?.tag).trim().toLowerCase()
const children = Array.isArray((this.node as any)?.children) ? (this.node as any).children : []
if (!content) {
container.innerHTML = ''
return
}
if (this.context?.allowHtml === false || ((this.node as any)?.loading && !(this.node as any)?.autoClosed)) {
container.textContent = content
return
}
if (tag && children.length > 0 && !NON_STRUCTURING_HTML_TAGS.has(tag)) {
container.innerHTML = renderMarkdownNodeToHtml(this.node, {
allowHtml: this.context?.allowHtml,
customHtmlTags: this.context?.customHtmlTags,
})
return
}
container.innerHTML = sanitizeHtmlContent(content)
}
}
@@ -0,0 +1,50 @@
import type { ElementRef } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input, ViewChild } from '@angular/core'
import { sanitizeHtmlContent } from '../../sanitizeHtmlContent'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-html-inline-node',
standalone: true,
template: '<span #containerRef class="html-inline-node"></span>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HtmlInlineNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@ViewChild('containerRef', { static: true }) private readonly containerRef?: ElementRef<HTMLElement>
ngAfterViewInit() {
this.commitContent()
}
ngOnChanges() {
this.commitContent()
}
ngOnDestroy() {
const container = this.containerRef?.nativeElement
if (container)
container.innerHTML = ''
}
private commitContent() {
const container = this.containerRef?.nativeElement
if (!container)
return
const content = getString((this.node as any)?.content)
if (!content) {
container.innerHTML = ''
return
}
if (this.context?.allowHtml === false || ((this.node as any)?.loading && !(this.node as any)?.autoClosed)) {
container.textContent = content
return
}
container.innerHTML = sanitizeHtmlContent(content)
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-image-node',
standalone: true,
template: `
<img
class="image-node__img"
[attr.src]="src"
[attr.alt]="alt"
[attr.title]="title || null"
[attr.loading]="'lazy'"
decoding="async"
>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ImageNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get src() {
return getString((this.node as any)?.src)
}
get alt() {
return getString((this.node as any)?.alt)
}
get title() {
return getString((this.node as any)?.title)
}
}
@@ -0,0 +1,487 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
HostListener,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getInfographic } from '../../optional/infographic'
import { clampPreviewHeight, estimateInfographicPreviewHeight, parsePositiveNumber } from '../shared/diagram-height'
import { getString } from '../shared/node-helpers'
import {
clampNumber,
clearElement,
copyTextToClipboard,
downloadSvgMarkup,
resolveCssSize,
setElementHtml,
} from '../shared/rich-block-helpers'
@Component({
selector: 'markstream-angular-infographic-block-node',
standalone: true,
imports: [CommonModule],
template: `
<div
class="markstream-angular-enhanced-block markstream-angular-enhanced-block--infographic"
[attr.data-markstream-infographic]="'1'"
[attr.data-mode]="showSource ? 'source' : 'preview'"
>
<div *ngIf="resolvedShowHeader" class="markstream-angular-enhanced-block__header">
<div class="flex items-center gap-2 min-w-0">
<span class="markstream-angular-enhanced-block__badge">Infographic</span>
<span class="mermaid-title__text truncate">Diagram</span>
</div>
<div class="markstream-angular-enhanced-block__actions">
<button
*ngIf="resolvedShowModeToggle"
type="button"
class="markstream-angular-enhanced-block__action mode-btn"
[class.is-active]="!showSource"
(click)="setMode(false)"
>
Preview
</button>
<button
*ngIf="resolvedShowModeToggle"
type="button"
class="markstream-angular-enhanced-block__action mode-btn"
[class.is-active]="showSource"
(click)="setMode(true)"
>
Source
</button>
<button
*ngIf="resolvedShowCopyButton"
type="button"
class="markstream-angular-enhanced-block__action"
[attr.title]="tooltipFor(copied ? 'Copied' : 'Copy')"
(click)="copySource()"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button
*ngIf="resolvedShowExportButton"
type="button"
class="markstream-angular-enhanced-block__action"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Export SVG')"
(click)="exportSvg()"
>
Export
</button>
<button
*ngIf="resolvedShowFullscreenButton"
type="button"
class="markstream-angular-enhanced-block__action"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Fullscreen')"
(click)="openModal()"
>
Fullscreen
</button>
<button
*ngIf="resolvedShowCollapseButton"
type="button"
class="markstream-angular-enhanced-block__action"
[attr.title]="tooltipFor(collapsed ? 'Expand' : 'Collapse')"
(click)="toggleCollapsed()"
>
{{ collapsed ? 'Expand' : 'Collapse' }}
</button>
<ng-container *ngIf="resolvedShowZoomControls && modalOpen">
<button type="button" class="markstream-angular-enhanced-block__action" [attr.title]="tooltipFor('Zoom out')" (click)="adjustZoom(-0.1)">-</button>
<button type="button" class="markstream-angular-enhanced-block__action" [attr.title]="tooltipFor('Reset zoom')" (click)="resetZoom()">100%</button>
<button type="button" class="markstream-angular-enhanced-block__action" [attr.title]="tooltipFor('Zoom in')" (click)="adjustZoom(0.1)">+</button>
</ng-container>
</div>
</div>
<div
*ngIf="!collapsed"
class="markstream-angular-enhanced-block__body"
[style.maxHeight]="resolvedMaxHeight"
>
<div *ngIf="rendering && !showSource" class="mermaid-loading">
<span class="mermaid-spinner" aria-hidden="true"></span>
<span>Rendering infographic...</span>
</div>
<div
*ngIf="!showSource"
#previewHost
class="infographic-render"
[style.minHeight]="resolvedContainerMinHeight"
></div>
<pre *ngIf="showSource" class="markstream-angular-enhanced-block__source"><code translate="no">{{ code }}</code></pre>
<div *ngIf="error" class="d2-error">{{ error }}</div>
</div>
<span class="sr-only" aria-live="polite" role="status">{{ copied ? 'Copied' : '' }}</span>
</div>
<div
*ngIf="modalOpen"
class="mermaid-modal-overlay"
role="dialog"
aria-modal="true"
(click)="closeModal()"
>
<div
class="mermaid-modal-panel"
[class.is-dark]="resolvedIsDark"
(click)="$event.stopPropagation()"
>
<div class="mermaid-modal-header">
<span class="mermaid-modal-title">Infographic Preview</span>
<button type="button" class="mermaid-modal-close" (click)="closeModal()">Close</button>
</div>
<div class="mermaid-modal-body">
<div class="mermaid-modal-content" [style.transform]="modalTransform">
<div #modalHost class="fullscreen"></div>
</div>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InfographicBlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('previewHost') private previewHost?: ElementRef<HTMLElement>
@ViewChild('modalHost') private modalHost?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() props?: Record<string, any>
rendering = false
copied = false
collapsed = false
showSource = false
modalOpen = false
zoom = 1
error = ''
containerMinHeight = ''
svgMarkup = ''
private viewReady = false
private destroyed = false
private renderToken = 0
private copyTimer: number | null = null
private infographicInstance: any = null
get mergedProps() {
return {
...(this.context?.infographicProps || {}),
...(this.props || {}),
}
}
get code() {
return getString((this.node as any)?.code)
}
get resolvedIsDark() {
if (typeof this.mergedProps.isDark === 'boolean')
return this.mergedProps.isDark
return this.context?.isDark === true
}
get resolvedShowHeader() {
return this.mergedProps.showHeader !== false
}
get resolvedShowCopyButton() {
return this.mergedProps.showCopyButton !== false
}
get resolvedShowCollapseButton() {
return this.mergedProps.showCollapseButton !== false
}
get resolvedShowModeToggle() {
return this.mergedProps.showModeToggle !== false
}
get resolvedShowExportButton() {
return this.mergedProps.showExportButton !== false
}
get resolvedShowFullscreenButton() {
return this.mergedProps.showFullscreenButton !== false
}
get resolvedShowZoomControls() {
return this.mergedProps.showZoomControls !== false
}
get resolvedShowTooltips() {
if (typeof this.mergedProps.showTooltips === 'boolean')
return this.mergedProps.showTooltips
return this.context?.showTooltips !== false
}
get resolvedMaxHeight() {
return resolveCssSize(this.mergedProps.maxHeight, '500px')
}
get estimatedPreviewHeightPx() {
return clampPreviewHeight(
parsePositiveNumber(this.mergedProps.estimatedPreviewHeightPx) ?? estimateInfographicPreviewHeight(this.code),
undefined,
this.maxPreviewHeight,
)
}
get resolvedContainerMinHeight() {
return this.containerMinHeight || `${this.estimatedPreviewHeightPx}px`
}
private get maxPreviewHeight() {
if (this.mergedProps.maxHeight == null || this.mergedProps.maxHeight === 'none')
return this.mergedProps.maxHeight === 'none' ? null : 500
return parsePositiveNumber(this.mergedProps.maxHeight) ?? 500
}
private resolveContainerMinHeight(actualHeight: number) {
const boundedHeight = Math.max(actualHeight, this.estimatedPreviewHeightPx)
const raw = this.mergedProps.maxHeight
if (raw == null || raw === 'none')
return `${boundedHeight}px`
const maxHeight = this.maxPreviewHeight
if (maxHeight == null)
return `${boundedHeight}px`
return `${Math.min(boundedHeight, maxHeight)}px`
}
get resolvedLoading() {
if (typeof this.mergedProps.loading === 'boolean')
return this.mergedProps.loading
return (this.node as any)?.loading === true
}
get resolvedProgressiveRender() {
return this.mergedProps.progressiveRender === true
}
get modalTransform() {
return `scale(${this.zoom})`
}
ngAfterViewInit() {
this.viewReady = true
queueMicrotask(() => void this.renderInfographic())
}
ngOnChanges() {
if (!this.viewReady)
return
this.containerMinHeight = ''
queueMicrotask(() => void this.renderInfographic())
}
ngOnDestroy() {
this.destroyed = true
this.renderToken += 1
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
this.destroyInfographic()
if (typeof document !== 'undefined')
document.body.style.overflow = ''
}
@HostListener('window:keydown', ['$event'])
handleWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && this.modalOpen)
this.closeModal()
}
tooltipFor(text: string) {
return this.resolvedShowTooltips ? text : null
}
setMode(nextSource: boolean) {
this.showSource = nextSource
if (!nextSource)
this.schedulePreviewSync()
this.cdr.markForCheck()
}
async copySource() {
await copyTextToClipboard(this.code)
this.copied = true
this.context?.events.onCopy?.(this.code)
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
if (typeof window !== 'undefined') {
this.copyTimer = window.setTimeout(() => {
this.copied = false
this.cdr.markForCheck()
}, 1000)
}
this.cdr.markForCheck()
}
exportSvg() {
if (!this.svgMarkup)
return
downloadSvgMarkup(this.svgMarkup, `infographic-${Date.now()}.svg`)
}
openModal() {
if (!this.svgMarkup)
return
this.modalOpen = true
if (typeof document !== 'undefined')
document.body.style.overflow = 'hidden'
queueMicrotask(() => this.syncModalPreview())
this.cdr.markForCheck()
}
closeModal() {
this.modalOpen = false
if (typeof document !== 'undefined')
document.body.style.overflow = ''
clearElement(this.modalHost?.nativeElement)
this.cdr.markForCheck()
}
toggleCollapsed() {
this.collapsed = !this.collapsed
if (!this.collapsed)
queueMicrotask(() => void this.renderInfographic())
this.cdr.markForCheck()
}
adjustZoom(delta: number) {
this.zoom = clampNumber(Number((this.zoom + delta).toFixed(2)), 0.4, 2.5)
this.cdr.markForCheck()
}
resetZoom() {
this.zoom = 1
this.cdr.markForCheck()
}
private async renderInfographic() {
if (this.destroyed || !this.viewReady || this.collapsed || this.showSource)
return
const source = this.code.trim()
if (!source) {
this.svgMarkup = ''
clearElement(this.previewHost?.nativeElement)
this.cdr.markForCheck()
return
}
const host = this.previewHost?.nativeElement
if (!host)
return
if (this.resolvedLoading && !this.resolvedProgressiveRender) {
this.renderToken += 1
this.rendering = true
this.error = ''
this.svgMarkup = ''
clearElement(host)
this.cdr.markForCheck()
return
}
const token = ++this.renderToken
this.rendering = true
this.error = ''
this.cdr.markForCheck()
try {
const InfographicClass = await getInfographic()
if (!InfographicClass)
throw new Error('Infographic renderer is not available.')
if (this.destroyed || token !== this.renderToken)
return
this.destroyInfographic()
clearElement(host)
const instance = new InfographicClass({
container: host,
width: '100%',
height: '100%',
})
this.infographicInstance = instance
instance.render(source)
if (this.destroyed || token !== this.renderToken)
return
const svg = host.querySelector('svg')
this.svgMarkup = svg ? svg.outerHTML : ''
const measuredHeight = host.scrollHeight
if (measuredHeight > 0)
this.containerMinHeight = this.resolveContainerMinHeight(measuredHeight)
this.syncModalPreview()
}
catch (error) {
if (this.destroyed || token !== this.renderToken)
return
this.destroyInfographic()
this.svgMarkup = ''
this.showSource = true
this.error = error instanceof Error ? error.message : 'Failed to render infographic.'
clearElement(host)
}
finally {
if (token === this.renderToken) {
this.rendering = false
this.cdr.markForCheck()
}
}
}
private destroyInfographic() {
try {
this.infographicInstance?.destroy?.()
}
catch {
// Best-effort cleanup.
}
this.infographicInstance = null
}
private syncModalPreview() {
if (!this.modalOpen || !this.svgMarkup) {
clearElement(this.modalHost?.nativeElement)
return
}
setElementHtml(this.modalHost?.nativeElement, this.svgMarkup)
}
private schedulePreviewSync() {
if (typeof window !== 'undefined') {
window.setTimeout(() => {
if (this.destroyed)
return
setElementHtml(this.previewHost?.nativeElement, this.svgMarkup)
this.syncModalPreview()
this.cdr.markForCheck()
}, 0)
return
}
queueMicrotask(() => {
setElementHtml(this.previewHost?.nativeElement, this.svgMarkup)
this.syncModalPreview()
})
}
}
@@ -0,0 +1,78 @@
import type { OnChanges } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
import { resolveStreamingTextState } from '../TextNode/streamingTextState'
@Component({
selector: 'markstream-angular-inline-code-node',
standalone: true,
imports: [CommonModule],
template: `
<code>
<span *ngIf="settledCode">{{ settledCode }}</span>
<span
*ngIf="streamedDelta"
[class]="'markstream-angular-text__stream-delta ' + streamedDeltaClass"
(animationend)="settleStreamedDelta()"
>{{ streamedDelta }}</span>
</code>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InlineCodeNodeComponent implements OnChanges {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
@Input() typewriter?: boolean
settledCode = ''
streamedDelta = ''
private streamFadeVersion = 0
ngOnChanges() {
const nextCode = getString((this.node as any)?.code)
const streamStateKey = getString(this.indexKey).trim()
const textStreamState = this.context?.textStreamState
const rendered = `${this.settledCode}${this.streamedDelta}`
const previousPersisted = streamStateKey
? textStreamState?.get(streamStateKey)
: undefined
const previousCode = previousPersisted ?? rendered
const nextState = resolveStreamingTextState({
nextContent: nextCode,
previousContent: previousCode,
typewriterEnabled: this.typewriterEnabled,
})
this.settledCode = nextState.settledContent
this.streamedDelta = nextState.streamedDelta
if (nextState.appended)
this.streamFadeVersion += 1
if (streamStateKey)
textStreamState?.set(streamStateKey, nextCode)
}
get streamedDeltaClass() {
return this.streamFadeVersion % 2 === 0
? 'markstream-angular-text__stream-delta--a'
: 'markstream-angular-text__stream-delta--b'
}
get typewriterEnabled() {
if (typeof this.typewriter === 'boolean')
return this.typewriter
if (typeof this.context?.typewriter === 'boolean')
return this.context.typewriter
return true
}
settleStreamedDelta() {
if (!this.streamedDelta)
return
this.settledCode = `${this.settledCode}${this.streamedDelta}`
this.streamedDelta = ''
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-insert-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<ins>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</ins>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InsertNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'insert'}-insert`
}
}
@@ -0,0 +1,156 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
forwardRef,
Input,
ViewChild,
} from '@angular/core'
import { hideTooltip, showTooltipForAnchor } from '../../tooltip/singletonTooltip'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList, getString } from '../shared/node-helpers'
import { TextNodeComponent } from '../TextNode/TextNode.component'
@Component({
selector: 'markstream-angular-link-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent), forwardRef(() => TextNodeComponent)],
template: `
<a
*ngIf="!loading; else loadingTpl"
#anchorEl
class="link-node"
[attr.href]="href || null"
[attr.title]="resolvedShowTooltip ? '' : title"
[attr.aria-label]="'Link: ' + title"
target="_blank"
rel="noopener noreferrer"
[ngStyle]="cssVars"
(mouseenter)="onAnchorEnter()"
(mouseleave)="onAnchorLeave()"
>
<markstream-angular-nested-renderer
*ngIf="hasChildren; else fallbackText"
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
<ng-template #fallbackText>
<markstream-angular-text-node
[node]="{ type: 'text', content: fallbackLabel }"
[context]="context"
[indexKey]="nestedPrefix + '-fallback'"
/>
</ng-template>
</a>
<ng-template #loadingTpl>
<span class="link-loading" [ngStyle]="cssVars" aria-hidden="false">
<span class="link-text-wrapper">
<span class="link-text">
<markstream-angular-text-node
[node]="{ type: 'text', content: fallbackLabel }"
[context]="context"
[indexKey]="nestedPrefix + '-loading'"
/>
</span>
<span class="link-loading-indicator" aria-hidden="true"></span>
</span>
</span>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LinkNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
@ViewChild('anchorEl') private anchorRef?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
private hovering = false
get href() {
return getString((this.node as any)?.href)
}
get title() {
const rawTitle = getString((this.node as any)?.title)
return rawTitle || this.href
}
get loading() {
return (this.node as any)?.loading === true
}
get resolvedShowTooltip() {
return this.context?.showTooltips !== false
}
get children() {
return getNodeList((this.node as any)?.children)
}
get hasChildren() {
return this.children.length > 0
}
get fallbackLabel() {
return getString((this.node as any)?.text || this.href)
}
get nestedPrefix() {
return `${this.indexKey || 'link'}-inline`
}
get cssVars() {
return {
'--link-color': '#0366d6',
'--underline-height': '2px',
'--underline-bottom': '-3px',
'--underline-opacity': '0.35',
'--underline-rest-opacity': '0.175',
'--underline-duration': '1.6s',
'--underline-timing': 'ease-in-out',
'--underline-iteration': 'infinite',
} as Record<string, string>
}
ngAfterViewInit() {
this.syncTooltip()
}
ngOnChanges() {
this.syncTooltip()
}
ngOnDestroy() {
if (this.hovering)
hideTooltip(true)
}
onAnchorEnter() {
if (!this.resolvedShowTooltip)
return
this.hovering = true
showTooltipForAnchor(this.anchorRef?.nativeElement || null, this.title, 'top', false, this.context?.isDark)
}
onAnchorLeave() {
if (!this.resolvedShowTooltip)
return
this.hovering = false
hideTooltip()
}
private syncTooltip() {
if (!this.hovering || !this.resolvedShowTooltip)
return
showTooltipForAnchor(this.anchorRef?.nativeElement || null, this.title, 'top', true, this.context?.isDark)
}
}
@@ -0,0 +1,35 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-list-item-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<li [attr.value]="value ?? null">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</li>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ListItemNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
@Input() value?: number
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'list-item'}-item`
}
}
@@ -0,0 +1,59 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { ListItemNodeComponent } from '../ListItemNode/ListItemNode.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-list-node',
standalone: true,
imports: [CommonModule, forwardRef(() => ListItemNodeComponent)],
template: `
<ol *ngIf="ordered; else unorderedList" class="list-node my-5 pl-[calc(13/8*1em)]" [attr.start]="startValue || null">
<markstream-angular-list-item-node
*ngFor="let item of items; let idx = index; trackBy: trackByIndex"
[node]="item"
[value]="startValue + idx"
[context]="context"
[indexKey]="itemPrefix(idx)"
/>
</ol>
<ng-template #unorderedList>
<ul class="list-node my-5 pl-[calc(13/8*1em)] list-disc max-lg:my-[calc(4/3*1em)] max-lg:pl-[calc(14/9*1em)]">
<markstream-angular-list-item-node
*ngFor="let item of items; let idx = index; trackBy: trackByIndex"
[node]="item"
[context]="context"
[indexKey]="itemPrefix(idx)"
/>
</ul>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ListNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get ordered() {
return !!(this.node as any)?.ordered
}
get startValue() {
return Number((this.node as any)?.start) || 1
}
get items() {
return getNodeList((this.node as any)?.items)
}
trackByIndex = (index: number) => {
return this.itemPrefix(index)
}
itemPrefix(index: number) {
return `${this.indexKey || 'list'}-${index}`
}
}
@@ -0,0 +1,126 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getKatex } from '../../optional/katex'
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-math-block-node',
standalone: true,
imports: [CommonModule],
template: `
<div class="math-block markstream-nested-math-block" data-markstream-katex-managed="1">
<div #mathRef class="markstream-nested-math-block__render" [class.math-rendering]="rendering"></div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MathBlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('mathRef') private mathRef?: ElementRef<HTMLDivElement>
@Input({ required: true }) node!: AngularRenderableNode
rendering = true
private destroyed = false
private renderVersion = 0
private hasRenderedOnce = false
get content() {
return getString((this.node as any)?.content || (this.node as any)?.markup || (this.node as any)?.raw)
}
ngAfterViewInit() {
void this.renderMath()
}
ngOnChanges() {
if (!this.mathRef)
return
void this.renderMath()
}
ngOnDestroy() {
this.destroyed = true
this.renderVersion += 1
}
private async renderMath() {
const target = this.mathRef?.nativeElement
if (!target)
return
const content = normalizeKaTeXRenderInput(this.content)
const version = ++this.renderVersion
if (!content) {
target.textContent = ''
this.rendering = false
this.cdr.markForCheck()
return
}
if (!this.hasRenderedOnce)
this.rendering = true
const html = await this.resolveKatexMarkup(content)
if (this.destroyed || version !== this.renderVersion)
return
if (html) {
target.innerHTML = html
this.hasRenderedOnce = true
this.rendering = false
}
else if (!(this.node as any)?.loading) {
target.textContent = getString((this.node as any)?.raw || content)
this.rendering = false
}
this.cdr.markForCheck()
}
private async resolveKatexMarkup(content: string) {
try {
return await renderKaTeXWithBackpressure(content, true, {
timeout: 3000,
waitTimeout: 2000,
maxRetries: 1,
})
}
catch (error: any) {
const code = error?.code || error?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || error?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
if (!isWorkerInitFailure && !isBusyOrTimeout)
return null
}
const katex = await getKatex()
if (!katex)
return null
try {
const html = katex.renderToString(content, {
throwOnError: (this.node as any)?.loading === true,
displayMode: true,
})
setKaTeXCache(content, true, html)
return html
}
catch {
return null
}
}
}
@@ -0,0 +1,134 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getKatex } from '../../optional/katex'
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-math-inline-node',
standalone: true,
imports: [CommonModule],
template: `
<span class="math-inline-wrapper markstream-nested-math" data-display="inline" data-markstream-katex-managed="1">
<span #mathRef class="math-inline" [class.math-inline--hidden]="loading"></span>
<span *ngIf="loading" class="math-inline__loading" role="status" aria-live="polite">
<span class="math-inline__spinner" aria-hidden="true"></span>
<span class="sr-only">Loading</span>
</span>
</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MathInlineNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('mathRef') private mathRef?: ElementRef<HTMLSpanElement>
@Input({ required: true }) node!: AngularRenderableNode
loading = true
private destroyed = false
private renderVersion = 0
private hasRenderedOnce = false
get content() {
return getString((this.node as any)?.content || (this.node as any)?.markup || (this.node as any)?.raw)
}
get displayMode() {
return String((this.node as any)?.markup || '') === '$$'
}
ngAfterViewInit() {
void this.renderMath()
}
ngOnChanges() {
if (!this.mathRef)
return
void this.renderMath()
}
ngOnDestroy() {
this.destroyed = true
this.renderVersion += 1
}
private async renderMath() {
const target = this.mathRef?.nativeElement
if (!target)
return
const content = normalizeKaTeXRenderInput(this.content)
const version = ++this.renderVersion
if (!content) {
target.textContent = ''
this.loading = false
this.cdr.markForCheck()
return
}
if (!this.hasRenderedOnce)
this.loading = true
const html = await this.resolveKatexMarkup(content, this.displayMode)
if (this.destroyed || version !== this.renderVersion)
return
if (html) {
target.innerHTML = html
this.hasRenderedOnce = true
this.loading = false
}
else if (!(this.node as any)?.loading) {
target.textContent = getString((this.node as any)?.raw || content)
this.loading = false
}
this.cdr.markForCheck()
}
private async resolveKatexMarkup(content: string, displayMode: boolean) {
try {
return await renderKaTeXWithBackpressure(content, displayMode, {
timeout: 1500,
waitTimeout: 0,
maxRetries: 0,
})
}
catch (error: any) {
const code = error?.code || error?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || error?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
if (!isWorkerInitFailure && !isBusyOrTimeout)
return null
}
const katex = await getKatex()
if (!katex)
return null
try {
const html = katex.renderToString(content, {
throwOnError: (this.node as any)?.loading === true,
displayMode,
})
setKaTeXCache(content, displayMode, html)
return html
}
catch {
return null
}
}
}
@@ -0,0 +1,761 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
HostListener,
inject,
Input,
ViewChild,
} from '@angular/core'
import { getMermaid } from '../../optional/mermaid'
import { toSafeSvgMarkup } from '../../sanitizeSvg'
import { canParseOffthread, findPrefixOffthread } from '../../workers/mermaidWorkerClient'
import { clampPreviewHeight, estimateMermaidPreviewHeight, parsePositiveNumber } from '../shared/diagram-height'
import { getString } from '../shared/node-helpers'
import {
clampNumber,
copyTextToClipboard,
downloadSvgMarkup,
resolveCssSize,
setElementHtml,
} from '../shared/rich-block-helpers'
let mermaidRenderSequence = 0
let mermaidRenderQueue: Promise<void> = Promise.resolve()
type MermaidTheme = 'light' | 'dark'
function enqueueMermaidRender<T>(run: () => Promise<T>) {
const next = mermaidRenderQueue.then(run, run)
mermaidRenderQueue = next.then(() => undefined, () => undefined)
return next
}
@Component({
selector: 'markstream-angular-mermaid-block-node',
standalone: true,
imports: [CommonModule],
template: `
<div
class="mermaid-block"
[class.dark]="resolvedIsDark"
[attr.data-markstream-mermaid]="'1'"
[attr.data-mode]="showSource ? 'source' : 'preview'"
>
<div *ngIf="resolvedShowHeader" class="mermaid-header">
<div class="flex items-center gap-2 min-w-0">
<span class="markstream-angular-enhanced-block__badge">Mermaid</span>
<span class="mermaid-title__text truncate">Diagram</span>
</div>
<div class="mermaid-actions">
<div *ngIf="resolvedShowModeToggle" class="mermaid-toggle" role="tablist" aria-label="Mermaid mode">
<button
type="button"
class="mermaid-toggle-btn"
[class.mermaid-toggle-btn--active]="!showSource"
[attr.aria-pressed]="!showSource"
(click)="setMode(false)"
>
Preview
</button>
<button
type="button"
class="mermaid-toggle-btn"
[class.mermaid-toggle-btn--active]="showSource"
[attr.aria-pressed]="showSource"
(click)="setMode(true)"
>
Source
</button>
</div>
<button
*ngIf="resolvedShowCopyButton"
type="button"
class="mermaid-btn"
[attr.title]="tooltipFor(copied ? 'Copied' : 'Copy')"
(click)="copySource()"
>
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button
*ngIf="resolvedShowExportButton"
type="button"
class="mermaid-btn"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Export SVG')"
(click)="exportSvg()"
>
Export
</button>
<button
*ngIf="resolvedShowFullscreenButton"
type="button"
class="mermaid-btn"
[disabled]="!svgMarkup"
[attr.title]="tooltipFor('Fullscreen')"
(click)="openModal()"
>
Fullscreen
</button>
<button
*ngIf="resolvedShowCollapseButton"
type="button"
class="mermaid-btn"
[attr.title]="tooltipFor(collapsed ? 'Expand' : 'Collapse')"
(click)="toggleCollapsed()"
>
{{ collapsed ? 'Expand' : 'Collapse' }}
</button>
<ng-container *ngIf="resolvedShowZoomControls && !showSource && !collapsed">
<button type="button" class="mermaid-btn" [attr.title]="tooltipFor('Zoom out')" (click)="adjustZoom(-0.1)">-</button>
<button type="button" class="mermaid-btn" [attr.title]="tooltipFor('Reset zoom')" (click)="resetZoom()">100%</button>
<button type="button" class="mermaid-btn" [attr.title]="tooltipFor('Zoom in')" (click)="adjustZoom(0.1)">+</button>
</ng-container>
</div>
</div>
<div *ngIf="!collapsed" class="mermaid-body">
<div *ngIf="rendering && !showSource" class="mermaid-loading">
<span class="mermaid-spinner" aria-hidden="true"></span>
<span>Rendering Mermaid...</span>
</div>
<div
*ngIf="!showSource"
class="mermaid-preview"
[style.maxHeight]="resolvedMaxHeight"
[style.minHeight.px]="estimatedPreviewHeightPx"
[style.height.px]="estimatedPreviewHeightPx"
[style.transform]="previewTransform"
>
<div #previewHost class="markstream-angular-mermaid" [class.is-empty]="!svgMarkup"></div>
</div>
<pre *ngIf="showSource" class="mermaid-source"><code translate="no">{{ code }}</code></pre>
<div *ngIf="error" class="mermaid-error">{{ error }}</div>
</div>
<span class="sr-only" aria-live="polite" role="status">{{ copied ? 'Copied' : '' }}</span>
</div>
<div
*ngIf="modalOpen"
class="mermaid-modal-overlay"
role="dialog"
aria-modal="true"
(click)="closeModal()"
>
<div
class="mermaid-modal-panel"
[class.is-dark]="resolvedIsDark"
(click)="$event.stopPropagation()"
>
<div class="mermaid-modal-header">
<span class="mermaid-modal-title">Mermaid Preview</span>
<button type="button" class="mermaid-modal-close" (click)="closeModal()">Close</button>
</div>
<div class="mermaid-modal-body">
<div
class="mermaid-modal-content"
[style.transform]="modalTransform"
(wheel)="handleModalWheel($event)"
>
<div #modalHost class="markstream-angular-mermaid fullscreen"></div>
</div>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MermaidBlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
@ViewChild('previewHost') private previewHost?: ElementRef<HTMLElement>
@ViewChild('modalHost') private modalHost?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() props?: Record<string, any>
rendering = false
copied = false
collapsed = false
showSource = false
modalOpen = false
zoom = 1
svgMarkup = ''
error = ''
private viewReady = false
private destroyed = false
private renderToken = 0
private copyTimer: number | null = null
get mergedProps() {
return {
...(this.context?.mermaidProps || {}),
...(this.props || {}),
}
}
get code() {
return getString((this.node as any)?.code)
.replace(/\]::([^:])/g, ']:::$1')
.replace(/:::subgraphNode$/gm, '::subgraphNode')
}
get resolvedIsDark() {
if (typeof this.mergedProps.isDark === 'boolean')
return this.mergedProps.isDark
return this.context?.isDark === true
}
get resolvedShowHeader() {
return this.mergedProps.showHeader !== false
}
get resolvedShowModeToggle() {
return this.mergedProps.showModeToggle !== false
}
get resolvedShowCopyButton() {
return this.mergedProps.showCopyButton !== false
}
get resolvedShowExportButton() {
return this.mergedProps.showExportButton !== false
}
get resolvedShowFullscreenButton() {
return this.mergedProps.showFullscreenButton !== false
}
get resolvedShowCollapseButton() {
return this.mergedProps.showCollapseButton !== false
}
get resolvedShowZoomControls() {
return this.mergedProps.showZoomControls !== false
}
get resolvedShowTooltips() {
if (typeof this.mergedProps.showTooltips === 'boolean')
return this.mergedProps.showTooltips
return this.context?.showTooltips !== false
}
get resolvedMaxHeight() {
return resolveCssSize(this.mergedProps.maxHeight, '500px')
}
get estimatedPreviewHeightPx() {
return clampPreviewHeight(
parsePositiveNumber(this.mergedProps.estimatedPreviewHeightPx) ?? estimateMermaidPreviewHeight(this.code),
undefined,
this.maxPreviewHeight,
)
}
private get maxPreviewHeight() {
if (this.mergedProps.maxHeight == null || this.mergedProps.maxHeight === 'none')
return this.mergedProps.maxHeight === 'none' ? null : 500
return parsePositiveNumber(this.mergedProps.maxHeight) ?? 500
}
get resolvedWorkerTimeout() {
return clampNumber(Number(this.mergedProps.workerTimeoutMs ?? 1400), 200, 10_000)
}
get resolvedParseTimeout() {
return clampNumber(Number(this.mergedProps.parseTimeoutMs ?? 1800), 200, 10_000)
}
get resolvedRenderTimeout() {
return clampNumber(Number(this.mergedProps.renderTimeoutMs ?? 2500), 200, 15_000)
}
get resolvedFullRenderTimeout() {
return clampNumber(Number(this.mergedProps.fullRenderTimeoutMs ?? 4000), 200, 20_000)
}
get strictMode() {
return this.mergedProps.isStrict === true
}
get resolvedLoading() {
if (typeof this.mergedProps.loading === 'boolean')
return this.mergedProps.loading
return (this.node as any)?.loading === true
}
get resolvedProgressiveRender() {
return this.mergedProps.progressiveRender === true
}
get previewTransform() {
return `scale(${this.zoom})`
}
get modalTransform() {
return `scale(${Math.max(1, this.zoom)})`
}
ngAfterViewInit() {
this.viewReady = true
queueMicrotask(() => void this.renderDiagram())
}
ngOnChanges() {
if (!this.viewReady)
return
queueMicrotask(() => void this.renderDiagram())
}
ngOnDestroy() {
this.destroyed = true
this.renderToken += 1
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
}
@HostListener('window:keydown', ['$event'])
handleWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && this.modalOpen)
this.closeModal()
}
tooltipFor(text: string) {
return this.resolvedShowTooltips ? text : null
}
setMode(nextSource: boolean) {
this.showSource = nextSource
if (!nextSource)
this.scheduleHostSync()
this.cdr.markForCheck()
}
async copySource() {
await copyTextToClipboard(this.code)
this.copied = true
this.context?.events.onCopy?.(this.code)
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
if (typeof window !== 'undefined') {
this.copyTimer = window.setTimeout(() => {
this.copied = false
this.cdr.markForCheck()
}, 1000)
}
this.cdr.markForCheck()
}
exportSvg() {
if (!this.svgMarkup)
return
downloadSvgMarkup(this.svgMarkup, `mermaid-${Date.now()}.svg`)
}
openModal() {
if (!this.svgMarkup)
return
this.modalOpen = true
if (typeof document !== 'undefined')
document.body.style.overflow = 'hidden'
queueMicrotask(() => this.syncSvgHosts())
this.cdr.markForCheck()
}
closeModal() {
this.modalOpen = false
if (typeof document !== 'undefined')
document.body.style.overflow = ''
this.cdr.markForCheck()
}
toggleCollapsed() {
this.collapsed = !this.collapsed
if (!this.collapsed)
queueMicrotask(() => void this.renderDiagram())
this.cdr.markForCheck()
}
adjustZoom(delta: number) {
this.zoom = clampNumber(Number((this.zoom + delta).toFixed(2)), 0.4, 2.5)
this.cdr.markForCheck()
}
resetZoom() {
this.zoom = 1
this.cdr.markForCheck()
}
handleModalWheel(event: WheelEvent) {
if (this.mergedProps.enableWheelZoom !== true)
return
event.preventDefault()
this.adjustZoom(event.deltaY > 0 ? -0.08 : 0.08)
}
private async renderDiagram() {
if (this.destroyed || !this.viewReady || this.collapsed)
return
const source = this.code.trim()
if (!source) {
this.svgMarkup = ''
this.error = ''
this.syncSvgHosts()
this.cdr.markForCheck()
return
}
if (this.resolvedLoading && !this.resolvedProgressiveRender) {
this.renderToken += 1
this.rendering = true
this.error = ''
this.svgMarkup = ''
this.syncSvgHosts()
this.cdr.markForCheck()
return
}
const token = ++this.renderToken
this.rendering = true
this.error = ''
this.cdr.markForCheck()
try {
const mermaid = await getMermaid(this.strictMode
? {
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
flowchart: { htmlLabels: false },
}
: {
startOnLoad: false,
securityLevel: 'loose',
suppressErrorRendering: true,
})
if (!mermaid)
throw new Error('Mermaid renderer is not available.')
const theme: MermaidTheme = this.resolvedIsDark ? 'dark' : 'light'
let renderSource = source
if (this.resolvedLoading && this.resolvedProgressiveRender) {
try {
const res = await this.canParseOrPrefix(source, theme, mermaid, token)
if (this.destroyed || token !== this.renderToken)
return
if (!res.fullOk && !res.prefix) {
this.rendering = false
this.cdr.markForCheck()
return
}
if (res.prefix)
renderSource = res.prefix
}
catch (error: any) {
if (error?.name === 'AbortError')
return
this.rendering = false
this.cdr.markForCheck()
return
}
}
const renderId = `markstream-angular-mermaid-${++mermaidRenderSequence}`
const themedSource = this.applyThemeTo(renderSource, theme)
const rendered = await enqueueMermaidRender(() => this.withTimeout(
() => Promise.resolve(mermaid.render(renderId, themedSource)),
this.resolvedLoading && this.resolvedProgressiveRender
? this.resolvedRenderTimeout
: this.resolvedFullRenderTimeout,
))
if (this.destroyed || token !== this.renderToken)
return
const svg = typeof rendered === 'string' ? rendered : rendered?.svg
if (this.isBrokenMermaidSvg(svg))
throw new Error('Mermaid produced invalid SVG during preview')
const safeSvg = toSafeSvgMarkup(svg)
if (!safeSvg)
throw new Error('Mermaid returned empty output.')
this.svgMarkup = safeSvg
if (!this.svgMarkup)
this.showSource = true
this.syncSvgHosts()
}
catch (error) {
if (this.destroyed || token !== this.renderToken)
return
if (this.resolvedLoading && this.resolvedProgressiveRender) {
this.svgMarkup = ''
this.syncSvgHosts()
return
}
// Allow consumer to handle the error via onRenderError callback
const onRenderError = this.mergedProps.onRenderError
if (typeof onRenderError === 'function' && this.previewHost?.nativeElement) {
const handled = onRenderError(error, this.code, this.previewHost.nativeElement)
if (handled === true) {
this.svgMarkup = ''
return
}
}
this.svgMarkup = ''
this.showSource = true
this.error = error instanceof Error ? error.message : 'Failed to render Mermaid diagram.'
this.syncSvgHosts()
}
finally {
if (token === this.renderToken) {
this.rendering = false
this.cdr.markForCheck()
}
}
}
private syncSvgHosts() {
setElementHtml(this.previewHost?.nativeElement, this.svgMarkup)
if (this.modalOpen)
setElementHtml(this.modalHost?.nativeElement, this.svgMarkup)
else
setElementHtml(this.modalHost?.nativeElement, '')
}
private scheduleHostSync() {
if (typeof window !== 'undefined') {
window.setTimeout(() => {
if (this.destroyed)
return
this.syncSvgHosts()
this.cdr.markForCheck()
}, 0)
return
}
queueMicrotask(() => this.syncSvgHosts())
}
private async canParseWithFallback(source: string, theme: MermaidTheme, mermaid: any, token: number) {
try {
const canParse = await canParseOffthread(source, theme, this.resolvedWorkerTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
if (canParse)
return true
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
const themedSource = this.applyThemeTo(source, theme)
const anyMermaid = mermaid as any
if (typeof anyMermaid.parse === 'function') {
await this.withTimeout(() => Promise.resolve(anyMermaid.parse(themedSource)), this.resolvedParseTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return true
}
const renderId = `markstream-angular-mermaid-parse-${++mermaidRenderSequence}`
await this.withTimeout(() => Promise.resolve(anyMermaid.render(renderId, themedSource)), this.resolvedParseTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return true
}
private async canParseOrPrefix(source: string, theme: MermaidTheme, mermaid: any, token: number) {
if (this.getMermaidDiagramKind(source) === 'gantt') {
const prefix = this.getSafePrefixCandidate(source)
if (!prefix.trim())
return { fullOk: false, prefix: '' }
await this.canParseWithFallback(prefix, theme, mermaid, token)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return prefix === source ? { fullOk: true, prefix: '' } : { fullOk: false, prefix }
}
try {
await this.canParseWithFallback(source, theme, mermaid, token)
return { fullOk: true, prefix: '' }
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
const prefix = await this.findPrefixCandidate(source, theme, token)
if (!prefix)
return { fullOk: false, prefix: '' }
await this.canParseWithFallback(prefix, theme, mermaid, token)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
return { fullOk: false, prefix }
}
private async findPrefixCandidate(source: string, theme: MermaidTheme, token: number) {
try {
const prefix = await findPrefixOffthread(source, theme, this.resolvedWorkerTimeout)
if (this.destroyed || token !== this.renderToken)
throw new DOMException('Aborted', 'AbortError')
if (prefix)
return prefix
}
catch (error: any) {
if (error?.name === 'AbortError')
throw error
}
return this.getSafePrefixCandidate(source)
}
private applyThemeTo(source: string, theme: MermaidTheme) {
const trimmed = source.trimStart()
if (trimmed.startsWith('%%{'))
return source
const themeValue = theme === 'dark' ? 'dark' : 'default'
return `%%{init: {"theme": "${themeValue}"}}%%\n${source}`
}
private getMermaidDiagramKind(source: string) {
for (const rawLine of source.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('%%'))
continue
const match = line.match(/^([A-Z][\w-]*)\b/i)
return match?.[1]?.toLowerCase() || ''
}
return ''
}
private isGanttTaskLine(rawLine: string) {
const line = rawLine.trim()
if (!line || line.startsWith('%%'))
return false
if (/^(?:gantt|title|dateformat|axisformat|tickinterval|excludes|section|todaymarker|topaxis|weekday|weekend|acctitle|accdescr|accdescrmultiline)\b/i.test(line))
return false
return line.includes(':')
}
private getSafeGanttPreviewCandidate(source: string) {
const lines = source.split(/\r?\n/)
if (!/\r?\n$/.test(source) && lines.length > 0)
lines.pop()
while (lines.length > 0) {
const last = lines[lines.length - 1]?.trim()
if (!last || last.startsWith('%%')) {
lines.pop()
continue
}
if (this.isGanttTaskLine(last))
break
lines.pop()
}
return lines.some(line => this.isGanttTaskLine(line)) ? lines.join('\n') : ''
}
private getSafePrefixCandidate(source: string) {
if (this.getMermaidDiagramKind(source) === 'gantt')
return this.getSafeGanttPreviewCandidate(source)
const lines = source.split('\n')
while (lines.length > 0) {
const lastRaw = lines[lines.length - 1]
const last = lastRaw.trimEnd()
if (!last) {
lines.pop()
continue
}
const looksDangling = /^[-=~>|<\s]+$/.test(last.trim())
|| /(?:--|==|~~|->|<-|-\||-\)|-x|o-|\|-|\.-)\s*$/.test(last)
|| /[-|><]$/.test(last)
|| /(?:graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt)\s*$/i.test(last)
if (!looksDangling)
break
lines.pop()
}
return lines.join('\n')
}
private isBrokenMermaidSvg(svg: string | null | undefined) {
if (!svg || typeof DOMParser === 'undefined')
return !svg
const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml')
const svgEl = parsed.documentElement
if (!svgEl || svgEl.nodeName.toLowerCase() !== 'svg')
return true
const viewBox = svgEl.getAttribute('viewBox')
if (viewBox) {
const parts = viewBox.trim().split(/[\s,]+/)
if (parts.length === 4) {
const width = Number.parseFloat(parts[2] || '')
const height = Number.parseFloat(parts[3] || '')
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
return true
}
}
const nodes = [svgEl, ...Array.from(svgEl.querySelectorAll('*'))]
for (const node of nodes) {
for (const attr of Array.from(node.attributes)) {
if (/\bNaN\b/i.test(attr.value))
return true
if (attr.name === 'style' && /max-width:\s*0(?:px)?/i.test(attr.value))
return true
}
}
return false
}
private withTimeout<T>(run: () => Promise<T>, timeoutMs: number) {
return new Promise<T>((resolve, reject) => {
let settled = false
const timer = typeof window !== 'undefined'
? window.setTimeout(() => {
if (settled)
return
settled = true
reject(new Error('Operation timed out'))
}, timeoutMs)
: null
run()
.then((value) => {
if (settled)
return
settled = true
if (timer != null && typeof window !== 'undefined')
window.clearTimeout(timer)
resolve(value)
})
.catch((error) => {
if (settled)
return
settled = true
if (timer != null && typeof window !== 'undefined')
window.clearTimeout(timer)
reject(error)
})
})
}
}
@@ -0,0 +1,141 @@
import type { OnChanges } from '@angular/core'
import type { MarkdownIt, ParseOptions } from 'stream-markdown-parser'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { mergeCustomHtmlTags } from 'stream-markdown-parser'
import { parseNestedMarkdownToNodes } from '../../parseNestedMarkdownToNodes'
import { NodeOutletComponent } from '../NodeOutlet/NodeOutlet.component'
import { normalizeTokenAttrs } from '../shared/node-helpers'
import { SafeAttrsDirective } from '../shared/safe-attrs.directive'
type NestedRenderItem
= | { kind: 'label', attrs: Record<string, string | true> | null, children: AngularRenderableNode[] }
| { kind: 'node', node: AngularRenderableNode }
@Component({
selector: 'markstream-angular-nested-renderer',
standalone: true,
imports: [
CommonModule,
SafeAttrsDirective,
forwardRef(() => NestedRendererComponent),
forwardRef(() => NodeOutletComponent),
],
template: `
<ng-container *ngFor="let item of items; let idx = index; trackBy: trackByIndex">
<label *ngIf="item.kind === 'label'; else nodeTemplate" [markstreamSafeAttrs]="item.attrs">
<markstream-angular-nested-renderer
[nodes]="item.children"
[context]="context"
[indexPrefix]="childPrefix(idx)"
/>
</label>
<ng-template #nodeTemplate>
<markstream-angular-node-outlet
[node]="$any(item).node"
[context]="context"
[indexKey]="childPrefix(idx)"
/>
</ng-template>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NestedRendererComponent implements OnChanges {
@Input() node?: AngularRenderableNode | null
@Input() nodes: readonly AngularRenderableNode[] | null | undefined
@Input() content?: string | null
@Input() loading?: boolean
@Input() final?: boolean
@Input() cacheKey?: string
@Input() parseOptions?: ParseOptions
@Input() customHtmlTags?: readonly string[]
@Input() customMarkdownIt?: (markdown: MarkdownIt) => MarkdownIt
@Input() context?: AngularRenderContext
@Input() indexPrefix = 'nested'
items: NestedRenderItem[] = []
ngOnChanges() {
this.items = this.buildItems(this.resolvedNodes)
}
trackByIndex = (index: number) => {
return `${this.indexPrefix}-${index}`
}
childPrefix(index: number) {
return `${this.indexPrefix}-${index}`
}
private get resolvedNodes() {
return parseNestedMarkdownToNodes(
{
node: this.node as any,
nodes: this.nodes as any,
content: this.content,
},
{
cacheKey: this.cacheKey,
final: this.resolveFinal(),
parseOptions: this.parseOptions ?? this.context?.parseOptions,
customHtmlTags: this.resolveCustomHtmlTags(),
customMarkdownIt: this.customMarkdownIt ?? this.context?.customMarkdownIt,
},
) as AngularRenderableNode[]
}
private buildItems(nodes: readonly AngularRenderableNode[] | null | undefined) {
const source = Array.isArray(nodes) ? nodes : []
const items: NestedRenderItem[] = []
for (let index = 0; index < source.length; index += 1) {
const node = source[index]
if (!node)
continue
if (node.type === 'label_open') {
const children: AngularRenderableNode[] = []
let cursor = index + 1
for (; cursor < source.length; cursor += 1) {
const segment = source[cursor]
if (segment?.type === 'label_close')
break
if (segment)
children.push(segment)
}
items.push({
kind: 'label',
attrs: normalizeTokenAttrs((node as any)?.attrs),
children,
})
index = cursor
continue
}
if (node.type === 'label_close')
continue
items.push({ kind: 'node', node })
}
return items
}
private resolveFinal() {
if (typeof this.final === 'boolean')
return this.final
if (typeof this.loading === 'boolean')
return !this.loading
if (typeof (this.node as any)?.loading === 'boolean')
return !(this.node as any).loading
return this.context?.final
}
private resolveCustomHtmlTags() {
return mergeCustomHtmlTags(this.context?.customHtmlTags, this.customHtmlTags)
}
}
@@ -0,0 +1,282 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { STANDARD_HTML_TAGS } from 'stream-markdown-parser'
import { getCustomNodeComponents } from '../../customComponents'
import { AdmonitionNodeComponent } from '../AdmonitionNode/AdmonitionNode.component'
import { BlockquoteNodeComponent } from '../BlockquoteNode/BlockquoteNode.component'
import { CheckboxNodeComponent } from '../CheckboxNode/CheckboxNode.component'
import { CodeBlockNodeComponent } from '../CodeBlockNode/CodeBlockNode.component'
import { D2BlockNodeComponent } from '../D2BlockNode/D2BlockNode.component'
import { DefinitionListNodeComponent } from '../DefinitionListNode/DefinitionListNode.component'
import { DynamicNodeHostComponent } from '../DynamicNodeHost/DynamicNodeHost.component'
import { EmojiNodeComponent } from '../EmojiNode/EmojiNode.component'
import { EmphasisNodeComponent } from '../EmphasisNode/EmphasisNode.component'
import { FallbackComponent } from '../FallbackComponent/FallbackComponent.component'
import { FootnoteAnchorNodeComponent } from '../FootnoteAnchorNode/FootnoteAnchorNode.component'
import { FootnoteNodeComponent } from '../FootnoteNode/FootnoteNode.component'
import { FootnoteReferenceNodeComponent } from '../FootnoteReferenceNode/FootnoteReferenceNode.component'
import { HardBreakNodeComponent } from '../HardBreakNode/HardBreakNode.component'
import { HeadingNodeComponent } from '../HeadingNode/HeadingNode.component'
import { HighlightNodeComponent } from '../HighlightNode/HighlightNode.component'
import { HtmlBlockNodeComponent } from '../HtmlBlockNode/HtmlBlockNode.component'
import { HtmlInlineNodeComponent } from '../HtmlInlineNode/HtmlInlineNode.component'
import { ImageNodeComponent } from '../ImageNode/ImageNode.component'
import { InfographicBlockNodeComponent } from '../InfographicBlockNode/InfographicBlockNode.component'
import { InlineCodeNodeComponent } from '../InlineCodeNode/InlineCodeNode.component'
import { InsertNodeComponent } from '../InsertNode/InsertNode.component'
import { LinkNodeComponent } from '../LinkNode/LinkNode.component'
import { ListItemNodeComponent } from '../ListItemNode/ListItemNode.component'
import { ListNodeComponent } from '../ListNode/ListNode.component'
import { MathBlockNodeComponent } from '../MathBlockNode/MathBlockNode.component'
import { MathInlineNodeComponent } from '../MathInlineNode/MathInlineNode.component'
import { MermaidBlockNodeComponent } from '../MermaidBlockNode/MermaidBlockNode.component'
import { ParagraphNodeComponent } from '../ParagraphNode/ParagraphNode.component'
import { PreCodeNodeComponent } from '../PreCodeNode/PreCodeNode.component'
import { ReferenceNodeComponent } from '../ReferenceNode/ReferenceNode.component'
import { hasCompleteHtmlTagContent } from '../shared/node-helpers'
import {
coerceBuiltinHtmlNode,
coerceCustomHtmlNode,
resolveHtmlTag,
resolveNodeOutletCodeMode,
resolveNodeOutletCustomComponent,
resolveNodeOutletCustomInputs,
} from '../shared/node-outlet-helpers'
import { StrikethroughNodeComponent } from '../StrikethroughNode/StrikethroughNode.component'
import { StrongNodeComponent } from '../StrongNode/StrongNode.component'
import { SubscriptNodeComponent } from '../SubscriptNode/SubscriptNode.component'
import { SuperscriptNodeComponent } from '../SuperscriptNode/SuperscriptNode.component'
import { TableNodeComponent } from '../TableNode/TableNode.component'
import { TextNodeComponent } from '../TextNode/TextNode.component'
import { ThematicBreakNodeComponent } from '../ThematicBreakNode/ThematicBreakNode.component'
import { VmrContainerNodeComponent } from '../VmrContainerNode/VmrContainerNode.component'
@Component({
selector: 'markstream-angular-node-outlet',
standalone: true,
imports: [
CommonModule,
forwardRef(() => AdmonitionNodeComponent),
forwardRef(() => BlockquoteNodeComponent),
forwardRef(() => CheckboxNodeComponent),
forwardRef(() => CodeBlockNodeComponent),
forwardRef(() => D2BlockNodeComponent),
forwardRef(() => DefinitionListNodeComponent),
forwardRef(() => DynamicNodeHostComponent),
forwardRef(() => EmojiNodeComponent),
forwardRef(() => EmphasisNodeComponent),
forwardRef(() => FallbackComponent),
forwardRef(() => FootnoteAnchorNodeComponent),
forwardRef(() => FootnoteNodeComponent),
forwardRef(() => FootnoteReferenceNodeComponent),
forwardRef(() => HardBreakNodeComponent),
forwardRef(() => HeadingNodeComponent),
forwardRef(() => HighlightNodeComponent),
forwardRef(() => HtmlBlockNodeComponent),
forwardRef(() => HtmlInlineNodeComponent),
forwardRef(() => ImageNodeComponent),
forwardRef(() => InfographicBlockNodeComponent),
forwardRef(() => InlineCodeNodeComponent),
forwardRef(() => InsertNodeComponent),
forwardRef(() => LinkNodeComponent),
forwardRef(() => ListItemNodeComponent),
forwardRef(() => ListNodeComponent),
forwardRef(() => MathBlockNodeComponent),
forwardRef(() => MathInlineNodeComponent),
forwardRef(() => MermaidBlockNodeComponent),
forwardRef(() => ParagraphNodeComponent),
forwardRef(() => PreCodeNodeComponent),
forwardRef(() => ReferenceNodeComponent),
forwardRef(() => StrikethroughNodeComponent),
forwardRef(() => StrongNodeComponent),
forwardRef(() => SubscriptNodeComponent),
forwardRef(() => SuperscriptNodeComponent),
forwardRef(() => TableNodeComponent),
forwardRef(() => TextNodeComponent),
forwardRef(() => ThematicBreakNodeComponent),
forwardRef(() => VmrContainerNodeComponent),
],
template: `
<ng-container *ngIf="resolvedCustomComponent as customComponent; else builtinTemplate">
<markstream-angular-dynamic-node-host
[component]="customComponent"
[node]="customNode"
[context]="context"
[indexKey]="indexKey"
[inputs]="resolvedComponentInputs"
/>
</ng-container>
<ng-template #builtinTemplate>
<ng-container [ngSwitch]="resolvedType">
<markstream-angular-text-node *ngSwitchCase="'text'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-text-node *ngSwitchCase="'text_special'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-paragraph-node *ngSwitchCase="'paragraph'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-heading-node *ngSwitchCase="'heading'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-blockquote-node *ngSwitchCase="'blockquote'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-list-node *ngSwitchCase="'list'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-list-item-node *ngSwitchCase="'list_item'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-table-node *ngSwitchCase="'table'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-definition-list-node *ngSwitchCase="'definition_list'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-footnote-node *ngSwitchCase="'footnote'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-footnote-reference-node *ngSwitchCase="'footnote_reference'" [node]="node" />
<markstream-angular-footnote-anchor-node *ngSwitchCase="'footnote_anchor'" [node]="node" />
<markstream-angular-admonition-node *ngSwitchCase="'admonition'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-hardbreak-node *ngSwitchCase="'hardbreak'" />
<markstream-angular-link-node *ngSwitchCase="'link'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-image-node *ngSwitchCase="'image'" [node]="node" />
<markstream-angular-inline-code-node *ngSwitchCase="'inline_code'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-strong-node *ngSwitchCase="'strong'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-emphasis-node *ngSwitchCase="'emphasis'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-strikethrough-node *ngSwitchCase="'strikethrough'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-highlight-node *ngSwitchCase="'highlight'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-insert-node *ngSwitchCase="'insert'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-subscript-node *ngSwitchCase="'subscript'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-superscript-node *ngSwitchCase="'superscript'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-checkbox-node *ngSwitchCase="'checkbox'" [node]="node" />
<markstream-angular-checkbox-node *ngSwitchCase="'checkbox_input'" [node]="node" />
<markstream-angular-emoji-node *ngSwitchCase="'emoji'" [node]="node" />
<markstream-angular-reference-node *ngSwitchCase="'reference'" [node]="node" />
<ng-container *ngSwitchCase="'html_block'">
<markstream-angular-text-node *ngIf="shouldEscapeHtmlTag; else htmlBlockNode" [node]="escapedTextNode" [context]="context" [indexKey]="indexKey" />
<ng-template #htmlBlockNode>
<markstream-angular-html-block-node [node]="htmlRenderNode" [context]="context" />
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="'html_inline'">
<markstream-angular-text-node *ngIf="shouldEscapeHtmlTag; else htmlInlineNode" [node]="escapedTextNode" [context]="context" [indexKey]="indexKey" />
<ng-template #htmlInlineNode>
<markstream-angular-html-inline-node [node]="htmlRenderNode" [context]="context" />
</ng-template>
</ng-container>
<markstream-angular-vmr-container-node *ngSwitchCase="'vmr_container'" [node]="node" [context]="context" [indexKey]="indexKey" />
<markstream-angular-thematic-break-node *ngSwitchCase="'thematic_break'" />
<markstream-angular-math-inline-node *ngSwitchCase="'math_inline'" [node]="node" />
<markstream-angular-math-block-node *ngSwitchCase="'math_block'" [node]="node" />
<ng-container *ngSwitchCase="'code_block'">
<markstream-angular-mermaid-block-node
*ngIf="codeMode === 'mermaid'; else nonMermaidCode"
[node]="node"
[context]="context"
/>
<ng-template #nonMermaidCode>
<markstream-angular-d2-block-node
*ngIf="codeMode === 'd2'; else nonD2Code"
[node]="node"
[context]="context"
/>
</ng-template>
<ng-template #nonD2Code>
<markstream-angular-infographic-block-node
*ngIf="codeMode === 'infographic'; else genericCode"
[node]="node"
[context]="context"
/>
</ng-template>
<ng-template #genericCode>
<markstream-angular-pre-code-node
*ngIf="codeMode === 'pre'; else enhancedCode"
[node]="node"
/>
</ng-template>
<ng-template #enhancedCode>
<markstream-angular-code-block-node [node]="node" [context]="context" />
</ng-template>
</ng-container>
<markstream-angular-fallback-node *ngSwitchDefault [node]="fallbackNode" [context]="context" [indexKey]="indexKey" />
</ng-container>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NodeOutletComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get resolvedType() {
return String((this.node as any)?.type || '')
}
get customComponentMap() {
return this.context?.customComponents || getCustomNodeComponents(this.context?.customId)
}
get resolvedCustomComponent() {
return resolveNodeOutletCustomComponent(this.node, this.context, this.customComponentMap)
}
get customNode() {
return coerceCustomHtmlNode(this.node)
}
get codeMode() {
return resolveNodeOutletCodeMode(this.node, this.context)
}
get htmlTag() {
return resolveHtmlTag(this.node)
}
/**
* Check if this HTML tag should be escaped (rendered as text) instead of being parsed as HTML.
* This happens when:
* 1. The node is html_block or html_inline
* 2. The tag is NOT in the customHtmlTags whitelist
* 3. The tag is NOT a standard HTML tag
* 4. The tag is incomplete/malformed, so rendering it as HTML would swallow
* surrounding content.
*/
get shouldEscapeHtmlTag() {
const type = this.resolvedType
if (type !== 'html_block' && type !== 'html_inline')
return false
const tag = this.htmlTag
if (!tag)
return false
// Check if tag is in whitelist
const customHtmlTags = this.context?.customHtmlTags ?? []
const isWhitelisted = customHtmlTags.some(t => String(t).toLowerCase() === tag)
if (isWhitelisted)
return false
// Check if tag is a standard HTML tag
if (STANDARD_HTML_TAGS.has(tag))
return false
if (hasCompleteHtmlTagContent((this.node as any)?.content ?? (this.node as any)?.raw, tag))
return false
return true
}
/**
* Returns a text node for incomplete non-whitelisted, non-standard HTML tags.
*/
get escapedTextNode(): AngularRenderableNode {
const rawContent = String((this.node as any)?.content ?? (this.node as any)?.raw ?? '')
return {
type: 'text',
content: rawContent,
raw: rawContent,
} as AngularRenderableNode
}
get htmlRenderNode() {
return coerceBuiltinHtmlNode(this.node, this.resolvedType)
}
get fallbackNode() {
return this.node
}
get resolvedComponentInputs() {
return resolveNodeOutletCustomInputs(this.node, this.context)
}
}
@@ -0,0 +1,762 @@
import type { AfterViewInit, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
import type { RenderedHtmlEnhancementHandle } from '../../enhanceRenderedHtml'
import type {
AngularRenderableNode,
AngularRenderContext,
NodeRendererEvents,
NodeRendererProps,
} from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
inject,
Input,
Output,
ViewChild,
} from '@angular/core'
import { getCustomNodeComponents, subscribeCustomComponents } from '../../customComponents'
import { disposeRenderedHtmlEnhancements, enhanceRenderedHtml } from '../../enhanceRenderedHtml'
import { NodeOutletComponent } from '../NodeOutlet/NodeOutlet.component'
import {
buildRenderContext,
resolveParsedNodes,
} from '../shared/node-helpers'
import {
computeLiveRange,
estimateHeightRange,
resolveAverageNodeHeight,
resolveDeferNodes,
resolveVirtualizationEnabled,
} from '../shared/render-window'
interface VisibleNodeEntry {
node: AngularRenderableNode
index: number
render: boolean
animate: boolean
placeholderHeight: number
}
interface ObserverConfig {
root: Element | null
rootMargin: string
threshold: number
}
interface IdleDeadlineLike {
timeRemaining?: () => number
}
const SCROLL_PARENT_OVERFLOW_RE = /auto|scroll|overlay/i
@Component({
selector: 'markstream-angular',
standalone: true,
imports: [CommonModule, NodeOutletComponent],
template: `
<div
#root
class="markstream-angular markdown-renderer"
[class.dark]="isDark"
[class.virtualized]="virtualizationEnabled"
(click)="click.emit($event)"
(mouseover)="handleMouseOver($event)"
(mouseout)="handleMouseOut($event)"
>
<div
*ngIf="topSpacerHeight > 0"
class="node-spacer"
[style.height.px]="topSpacerHeight"
aria-hidden="true"
></div>
<div
*ngFor="let entry of visibleEntries; trackBy: trackByEntry"
class="node-slot"
[attr.data-node-index]="entry.index"
[attr.data-node-type]="entry.node.type"
>
<div
*ngIf="entry.render; else nodePlaceholder"
class="node-content"
[class.typewriter-node]="entry.animate"
[attr.data-node-index]="entry.index"
>
<markstream-angular-node-outlet
[node]="entry.node"
[context]="renderContext"
[indexKey]="entryIndexKey(entry.index)"
/>
</div>
<ng-template #nodePlaceholder>
<div class="node-placeholder" [style.height.px]="entry.placeholderHeight"></div>
</ng-template>
</div>
<div
*ngIf="bottomSpacerHeight > 0"
class="node-spacer"
[style.height.px]="bottomSpacerHeight"
aria-hidden="true"
></div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NodeRendererComponent implements NodeRendererProps, OnChanges, OnInit, AfterViewInit, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
private readonly hostRef = inject(ElementRef<HTMLElement>)
@ViewChild('root') private rootRef?: ElementRef<HTMLElement>
@Input() content = ''
@Input() nodes?: readonly AngularRenderableNode[] | null
@Input() final?: boolean
@Input() parseOptions?: NodeRendererProps['parseOptions']
@Input() customMarkdownIt?: NodeRendererProps['customMarkdownIt']
@Input() debugPerformance?: boolean
@Input() customHtmlTags?: readonly string[]
@Input() viewportPriority?: boolean
@Input() codeBlockStream = true
@Input() codeBlockDarkTheme?: any
@Input() codeBlockLightTheme?: any
@Input() codeBlockMonacoOptions?: Record<string, any>
@Input() renderCodeBlocksAsPre = false
@Input() codeBlockMinWidth?: string | number
@Input() codeBlockMaxWidth?: string | number
@Input() codeBlockProps?: Record<string, any>
@Input() mermaidProps?: Record<string, any>
@Input() d2Props?: Record<string, any>
@Input() infographicProps?: Record<string, any>
@Input() customComponents?: NodeRendererProps['customComponents']
@Input() showTooltips = true
@Input() themes?: string[]
@Input() isDark = false
@Input() customId?: string
@Input() indexKey?: number | string
@Input() typewriter = true
@Input() batchRendering = true
@Input() initialRenderBatchSize = 40
@Input() renderBatchSize = 80
@Input() renderBatchDelay = 16
@Input() renderBatchBudgetMs = 6
@Input() renderBatchIdleTimeoutMs = 120
@Input() deferNodesUntilVisible = true
@Input() maxLiveNodes = 320
@Input() liveNodeBuffer = 60
@Input() allowHtml = true
@Output() copy = new EventEmitter<string>()
@Output() handleArtifactClick = new EventEmitter<any>()
@Output() click = new EventEmitter<MouseEvent>()
@Output() mouseover = new EventEmitter<MouseEvent>()
@Output() mouseout = new EventEmitter<MouseEvent>()
parsedNodes: AngularRenderableNode[] = []
visibleEntries: VisibleNodeEntry[] = []
renderContext: AngularRenderContext = { events: {} }
topSpacerHeight = 0
bottomSpacerHeight = 0
private renderedCount = 0
private focusIndex = 0
private liveRange = { start: 0, end: 0 }
private readonly nodeHeights = new Map<number, number>()
private readonly nodeVisibility = new Set<number>()
private readonly nodeSeen = new Set<number>()
private readonly textStreamState = new Map<string, string>()
private streamRenderVersion = 0
private readonly slotElements = new Map<number, HTMLElement>()
private readonly observedElements = new Map<number, HTMLElement>()
private observer: IntersectionObserver | null = null
private observerConfig: ObserverConfig | null = null
private readonly observerIndexByElement = new Map<Element, number>()
private batchTimer: number | null = null
private batchRaf: number | null = null
private batchIdle: number | null = null
private batchPending = false
private enhancementToken = 0
private enhancementTimer: number | null = null
private enhancementHandle: RenderedHtmlEnhancementHandle | null = null
private postRenderTimer: number | null = null
private unsubscribeCustomComponents?: () => void
get resolvedIndexPrefix() {
return this.indexKey != null ? String(this.indexKey) : 'renderer'
}
get resolvedInitialBatchSize() {
return Math.max(1, Math.trunc(this.initialRenderBatchSize || 40))
}
get resolvedRenderBatchSize() {
return Math.max(1, Math.trunc(this.renderBatchSize || 80))
}
get resolvedMaxLiveNodes() {
return Math.max(1, Math.trunc(this.maxLiveNodes ?? 320))
}
get resolvedLiveNodeBuffer() {
return Math.max(0, Math.trunc(this.liveNodeBuffer ?? 60))
}
get liveWindowEnabled() {
const configuredMaxLiveNodes = Math.trunc(this.maxLiveNodes ?? 320)
return Number.isFinite(configuredMaxLiveNodes) && configuredMaxLiveNodes > 0
}
get viewportPriorityEnabled() {
return this.viewportPriority !== false
}
get virtualizationEnabled() {
return resolveVirtualizationEnabled(this.parsedNodes.length, this.maxLiveNodes)
}
get incrementalRenderingActive() {
return this.batchRendering !== false
&& typeof window !== 'undefined'
&& !this.liveWindowEnabled
&& !this.virtualizationEnabled
&& this.parsedNodes.length > this.resolvedInitialBatchSize
}
get deferNodesActive() {
return resolveDeferNodes({
deferNodesUntilVisible: this.deferNodesUntilVisible,
maxLiveNodes: this.maxLiveNodes,
parsedNodeCount: this.parsedNodes.length,
viewportPriority: this.viewportPriorityEnabled,
virtualizationEnabled: this.virtualizationEnabled,
})
}
get shouldObserveSlots() {
return this.deferNodesActive || this.virtualizationEnabled
}
ngOnChanges(changes: SimpleChanges) {
if (changes.content || changes.nodes)
this.streamRenderVersion += 1
this.rebuild()
}
ngOnInit() {
this.unsubscribeCustomComponents = subscribeCustomComponents(() => {
this.rebuild()
})
}
ngAfterViewInit() {
this.schedulePostRenderWork()
}
ngOnDestroy() {
this.stopBatching()
this.clearPostRenderWork()
this.enhancementToken += 1
this.disposeEnhancements()
this.unsubscribeCustomComponents?.()
this.unsubscribeCustomComponents = undefined
this.disconnectObserver()
if (this.enhancementTimer != null && typeof window !== 'undefined') {
window.clearTimeout(this.enhancementTimer)
this.enhancementTimer = null
}
}
trackByEntry = (_index: number, entry: VisibleNodeEntry) => {
return `${this.resolvedIndexPrefix}-${entry.index}`
}
entryIndexKey(index: number) {
return `${this.resolvedIndexPrefix}-${index}`
}
handleMouseOver(event: MouseEvent) {
const target = event.target as HTMLElement | null
if (target?.closest('[data-node-index]'))
this.mouseover.emit(event)
}
handleMouseOut(event: MouseEvent) {
const target = event.target as HTMLElement | null
if (target?.closest('[data-node-index]'))
this.mouseout.emit(event)
}
private rebuild() {
this.stopBatching()
this.disposeEnhancements()
this.cleanupTransientState()
const scopedCustomComponents = getCustomNodeComponents(this.customId)
const mergedCustomComponents = this.customComponents
? {
...scopedCustomComponents,
...this.customComponents,
}
: scopedCustomComponents
const events: NodeRendererEvents = {
onCopy: code => this.copy.emit(code),
onHandleArtifactClick: payload => this.handleArtifactClick.emit(payload),
}
this.renderContext = buildRenderContext(
{
...this,
customComponents: mergedCustomComponents,
},
events,
this.textStreamState,
this.streamRenderVersion,
)
this.parsedNodes = resolveParsedNodes(this)
this.trimMeasuredState()
const total = this.parsedNodes.length
this.focusIndex = Math.max(0, Math.min(this.focusIndex, Math.max(0, total - 1)))
if (this.virtualizationEnabled) {
this.renderedCount = total
this.liveRange = computeLiveRange(total, this.focusIndex, this.resolvedMaxLiveNodes, this.resolvedLiveNodeBuffer)
}
else {
this.liveRange = { start: 0, end: total }
this.renderedCount = this.incrementalRenderingActive
? Math.min(total, this.resolvedInitialBatchSize)
: total
}
if (!this.deferNodesActive)
this.nodeVisibility.clear()
this.refreshVisibleEntries()
this.cdr.markForCheck()
this.schedulePostRenderWork()
if (this.incrementalRenderingActive && this.renderedCount < total)
this.scheduleBatchStep()
}
private cleanupTransientState() {
this.slotElements.clear()
this.disconnectObserver()
}
private trimMeasuredState() {
const total = this.parsedNodes.length
for (const index of Array.from(this.nodeHeights.keys())) {
if (index >= total)
this.nodeHeights.delete(index)
}
for (const index of Array.from(this.nodeSeen)) {
if (index >= total)
this.nodeSeen.delete(index)
}
for (const index of Array.from(this.nodeVisibility)) {
if (index >= total)
this.nodeVisibility.delete(index)
}
}
private refreshVisibleEntries() {
const averageNodeHeight = resolveAverageNodeHeight(this.nodeHeights)
const total = this.parsedNodes.length
const source = this.virtualizationEnabled
? this.parsedNodes.slice(this.liveRange.start, this.liveRange.end).map((node, offset) => ({
node,
index: this.liveRange.start + offset,
}))
: this.parsedNodes.map((node, index) => ({ node, index }))
this.topSpacerHeight = this.virtualizationEnabled
? estimateHeightRange(0, Math.min(this.liveRange.start, total), this.nodeHeights, averageNodeHeight)
: 0
this.bottomSpacerHeight = this.virtualizationEnabled
? estimateHeightRange(Math.min(this.liveRange.end, total), total, this.nodeHeights, averageNodeHeight)
: 0
this.visibleEntries = source.map(({ node, index }) => {
const render = this.shouldRenderNode(index)
let animate = false
if (
render
&& this.typewriter !== false
&& String((node as any)?.type || '') !== 'code_block'
&& !this.nodeSeen.has(index)
) {
animate = true
this.nodeSeen.add(index)
}
return {
node,
index,
render,
animate,
placeholderHeight: this.nodeHeights.get(index) ?? averageNodeHeight,
}
})
}
private shouldRenderNode(index: number) {
if (this.incrementalRenderingActive && index >= this.renderedCount)
return false
if (!this.deferNodesActive)
return true
if (index < this.resolvedInitialBatchSize)
return true
return this.nodeVisibility.has(index)
}
private scheduleBatchStep() {
if (!this.incrementalRenderingActive || typeof window === 'undefined')
return
if (this.batchPending || this.renderedCount >= this.parsedNodes.length)
return
this.batchPending = true
const run = (deadline?: IdleDeadlineLike) => {
this.batchPending = false
this.batchTimer = null
this.batchRaf = null
this.batchIdle = null
const budget = Math.max(2, Math.trunc(this.renderBatchBudgetMs || 6))
let nextCount = this.renderedCount
do {
nextCount = Math.min(this.parsedNodes.length, nextCount + this.resolvedRenderBatchSize)
if (!deadline)
break
const remaining = typeof deadline.timeRemaining === 'function' ? deadline.timeRemaining() : 0
if (remaining <= budget * 0.5)
break
} while (nextCount < this.parsedNodes.length)
if (nextCount !== this.renderedCount) {
this.renderedCount = nextCount
this.refreshVisibleEntries()
this.cdr.markForCheck()
this.schedulePostRenderWork()
}
if (this.renderedCount < this.parsedNodes.length)
this.scheduleBatchStep()
}
const idleCallback = (window as any).requestIdleCallback as ((cb: (deadline: IdleDeadlineLike) => void, opts?: { timeout?: number }) => number) | undefined
const delay = Math.max(0, Math.trunc(this.renderBatchDelay || 0))
if (idleCallback) {
this.batchIdle = idleCallback(run, { timeout: Math.max(0, Math.trunc(this.renderBatchIdleTimeoutMs || 120)) })
return
}
if (typeof window.requestAnimationFrame === 'function') {
this.batchRaf = window.requestAnimationFrame(() => {
if (delay === 0) {
run()
return
}
this.batchTimer = window.setTimeout(() => run(), delay)
})
return
}
this.batchTimer = window.setTimeout(() => run(), delay)
}
private stopBatching() {
this.batchPending = false
if (this.batchTimer != null && typeof window !== 'undefined') {
window.clearTimeout(this.batchTimer)
this.batchTimer = null
}
if (this.batchRaf != null && typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(this.batchRaf)
this.batchRaf = null
}
const cancelIdle = (typeof window !== 'undefined' ? (window as any).cancelIdleCallback : null) as ((id: number) => void) | null
if (this.batchIdle != null && cancelIdle) {
cancelIdle(this.batchIdle)
this.batchIdle = null
}
}
private schedulePostRenderWork() {
if (typeof window === 'undefined')
return
this.clearPostRenderWork()
this.postRenderTimer = window.setTimeout(() => {
this.postRenderTimer = null
this.syncSlotObservers()
this.measureNodeHeights()
this.scheduleEnhancements()
}, 0)
}
private clearPostRenderWork() {
if (this.postRenderTimer != null && typeof window !== 'undefined') {
window.clearTimeout(this.postRenderTimer)
this.postRenderTimer = null
}
}
private syncSlotObservers() {
const root = this.rootRef?.nativeElement
if (!root)
return
const currentIndices = new Set<number>()
const slots = Array.from(root.querySelectorAll<HTMLElement>('.node-slot[data-node-index]'))
for (const slot of slots) {
const index = Number(slot.dataset.nodeIndex)
if (!Number.isFinite(index))
continue
currentIndices.add(index)
this.slotElements.set(index, slot)
this.observeSlot(index, slot)
}
for (const [index] of Array.from(this.slotElements.entries())) {
if (currentIndices.has(index))
continue
this.slotElements.delete(index)
this.unobserveIndex(index)
}
}
private observeSlot(index: number, slot: HTMLElement) {
if (!this.shouldObserveSlots) {
this.unobserveIndex(index)
this.markNodeVisible(index)
return
}
if (index < this.resolvedInitialBatchSize && !this.virtualizationEnabled) {
this.unobserveIndex(index)
this.markNodeVisible(index)
return
}
const existing = this.observedElements.get(index)
if (existing === slot)
return
this.unobserveIndex(index)
const observer = this.ensureObserver('400px', 0)
if (!observer) {
this.markNodeVisible(index)
return
}
this.observedElements.set(index, slot)
this.observerIndexByElement.set(slot, index)
observer.observe(slot)
}
private ensureObserver(rootMargin: string, threshold: number) {
if (typeof window === 'undefined')
return null
if (typeof IntersectionObserver === 'undefined')
return null
if (!this.viewportPriorityEnabled && !this.virtualizationEnabled)
return null
const nextConfig: ObserverConfig = {
root: this.resolveViewportRoot(),
rootMargin,
threshold,
}
if (
this.observer
&& this.observerConfig
&& this.observerConfig.root === nextConfig.root
&& this.observerConfig.rootMargin === nextConfig.rootMargin
&& this.observerConfig.threshold === nextConfig.threshold
) {
return this.observer
}
this.disconnectObserver()
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!(entry.isIntersecting || entry.intersectionRatio > 0))
continue
const index = this.observerIndexByElement.get(entry.target)
if (index == null)
continue
this.unobserveIndex(index)
this.markNodeVisible(index)
}
}, nextConfig)
this.observerConfig = nextConfig
return this.observer
}
private resolveViewportRoot() {
const base = this.rootRef?.nativeElement || this.hostRef.nativeElement
if (!base || typeof window === 'undefined')
return null
const doc = base.ownerDocument || document
const rootScrollable = doc.scrollingElement || doc.documentElement
let current = base.parentElement
while (current) {
if (current === doc.body || current === rootScrollable)
break
const style = window.getComputedStyle(current)
const overflowY = (style.overflowY || '').toLowerCase()
const overflow = (style.overflow || '').toLowerCase()
if (SCROLL_PARENT_OVERFLOW_RE.test(overflowY) || SCROLL_PARENT_OVERFLOW_RE.test(overflow))
return current
current = current.parentElement
}
return null
}
private markNodeVisible(index: number) {
let changed = false
if (this.deferNodesActive && !this.nodeVisibility.has(index)) {
this.nodeVisibility.add(index)
changed = true
}
if (this.virtualizationEnabled && index > this.focusIndex) {
this.focusIndex = index
this.liveRange = computeLiveRange(
this.parsedNodes.length,
this.focusIndex,
this.resolvedMaxLiveNodes,
this.resolvedLiveNodeBuffer,
)
changed = true
}
if (!changed)
return
this.refreshVisibleEntries()
this.cdr.markForCheck()
this.schedulePostRenderWork()
}
private unobserveIndex(index: number) {
const element = this.observedElements.get(index)
if (!element)
return
try {
this.observer?.unobserve(element)
}
catch {
// Ignore observer cleanup failures.
}
this.observerIndexByElement.delete(element)
this.observedElements.delete(index)
}
private disconnectObserver() {
try {
this.observer?.disconnect()
}
catch {
// Ignore observer cleanup failures.
}
this.observer = null
this.observerConfig = null
this.observerIndexByElement.clear()
this.observedElements.clear()
}
private measureNodeHeights() {
const root = this.rootRef?.nativeElement
if (!root)
return
let changed = false
const contentNodes = Array.from(root.querySelectorAll<HTMLElement>('.node-content[data-node-index]'))
for (const element of contentNodes) {
const index = Number(element.dataset.nodeIndex)
if (!Number.isFinite(index))
continue
const height = element.offsetHeight
if (!height)
continue
if (this.nodeHeights.get(index) !== height) {
this.nodeHeights.set(index, height)
changed = true
}
}
if (!changed)
return
this.refreshVisibleEntries()
this.cdr.markForCheck()
}
private scheduleEnhancements() {
if (typeof window === 'undefined')
return
const token = ++this.enhancementToken
if (this.enhancementTimer != null)
window.clearTimeout(this.enhancementTimer)
this.enhancementTimer = window.setTimeout(() => {
this.enhancementTimer = null
void this.applyEnhancements(token)
}, 0)
}
private async applyEnhancements(token: number) {
if (token !== this.enhancementToken)
return
const root = this.rootRef?.nativeElement || this.hostRef.nativeElement.querySelector('.markstream-angular') as HTMLElement | null
if (!root)
return
const handle = await enhanceRenderedHtml(root, {
final: this.final,
isDark: this.isDark,
renderCodeBlocksAsPre: this.renderCodeBlocksAsPre,
monacoOptions: this.codeBlockMonacoOptions,
d2ThemeId: this.d2Props?.themeId ?? null,
d2DarkThemeId: this.d2Props?.darkThemeId ?? null,
showTooltips: this.showTooltips,
codeBlockProps: this.codeBlockProps,
mermaidProps: this.mermaidProps,
d2Props: this.d2Props,
infographicProps: this.infographicProps,
onCopy: code => this.copy.emit(code),
isCancelled: () => token !== this.enhancementToken,
})
if (token !== this.enhancementToken) {
handle.dispose()
return
}
this.enhancementHandle = handle
this.measureNodeHeights()
}
private disposeEnhancements() {
this.enhancementHandle?.dispose()
this.enhancementHandle = null
const root = this.rootRef?.nativeElement || this.hostRef.nativeElement.querySelector('.markstream-angular') as HTMLElement | null
if (root)
disposeRenderedHtmlEnhancements(root)
}
}
@@ -0,0 +1,125 @@
import type { OnChanges } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { NodeOutletComponent } from '../NodeOutlet/NodeOutlet.component'
import {
getNodeList,
getString,
isMediaOnlyParagraphNodes,
isWhitespaceTextNode,
normalizeMediaOnlyParagraphNodes,
splitParagraphChildren,
} from '../shared/node-helpers'
type ParagraphSegment
= | { kind: 'inline', nodes: AngularRenderableNode[], mediaOnly: boolean }
| { kind: 'block', node: AngularRenderableNode }
@Component({
selector: 'markstream-angular-paragraph-node',
standalone: true,
imports: [
CommonModule,
forwardRef(() => NestedRendererComponent),
forwardRef(() => NodeOutletComponent),
],
template: `
<ng-container *ngIf="segments.length > 0; else simpleParagraph">
<ng-container *ngFor="let segment of segments; let idx = index; trackBy: trackByIndex">
<p *ngIf="segment.kind === 'inline'; else blockSegment" dir="auto" class="paragraph-node">
<ng-container *ngIf="$any(segment).mediaOnly; else nestedInline">
<ng-container *ngFor="let child of $any(segment).nodes; let childIdx = index; trackBy: trackByChildIndex">
<ng-container *ngIf="isWhitespaceText(child); else inlineNode">{{ getTextContent(child) }}</ng-container>
<ng-template #inlineNode>
<markstream-angular-node-outlet
[node]="child"
[context]="context"
[indexKey]="childPrefix(idx, childIdx)"
/>
</ng-template>
</ng-container>
</ng-container>
<ng-template #nestedInline>
<markstream-angular-nested-renderer
[nodes]="$any(segment).nodes"
[context]="context"
[indexPrefix]="segmentPrefix(idx)"
/>
</ng-template>
</p>
<ng-template #blockSegment>
<markstream-angular-node-outlet
[node]="$any(segment).node"
[context]="context"
[indexKey]="segmentPrefix(idx)"
/>
</ng-template>
</ng-container>
</ng-container>
<ng-template #simpleParagraph>
<p dir="auto" class="paragraph-node">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="indexKey || 'paragraph'"
/>
</p>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ParagraphNodeComponent implements OnChanges {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
segments: ParagraphSegment[] = []
get children() {
return getNodeList((this.node as any)?.children)
}
ngOnChanges() {
this.segments = splitParagraphChildren(this.children).map((segment) => {
if (segment.kind === 'block')
return segment
const mediaOnly = isMediaOnlyParagraphNodes(segment.nodes)
return {
kind: 'inline',
nodes: mediaOnly ? normalizeMediaOnlyParagraphNodes(segment.nodes) : segment.nodes,
mediaOnly,
}
})
}
trackByIndex = (index: number) => {
return `${this.indexKey || 'paragraph'}-${index}`
}
trackByChildIndex = (index: number) => {
return index
}
segmentPrefix(index: number) {
return `${this.indexKey || 'paragraph'}-${index}`
}
childPrefix(segmentIndex: number, childIndex: number) {
return `${this.segmentPrefix(segmentIndex)}-${childIndex}`
}
isWhitespaceText(node: AngularRenderableNode) {
return isWhitespaceTextNode(node)
}
getTextContent(node: AngularRenderableNode) {
return getString((node as any)?.content)
}
}
@@ -0,0 +1,33 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString, normalizeCodeLanguage } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-pre-code-node',
standalone: true,
template: `<pre [class]="languageClass" [attr.aria-busy]="loading" [attr.aria-label]="ariaLabel" [attr.data-language]="language" tabindex="0"><code translate="no" [textContent]="code"></code></pre>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PreCodeNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get language() {
return normalizeCodeLanguage((this.node as any)?.language)
}
get languageClass() {
return `language-${this.language}`
}
get ariaLabel() {
return this.language ? `Code block: ${this.language}` : 'Code block'
}
get code() {
return getString((this.node as any)?.code)
}
get loading() {
return (this.node as any)?.loading === true
}
}
@@ -0,0 +1,17 @@
import type { AngularRenderableNode } from '../shared/node-helpers'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-reference-node',
standalone: true,
template: '<span class="markstream-nested-reference">{{ id }}</span>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReferenceNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
get id() {
return getString((this.node as any)?.id)
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-strikethrough-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<del>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</del>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StrikethroughNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'strikethrough'}-strike`
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-strong-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<strong>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</strong>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StrongNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'strong'}-strong`
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-subscript-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<sub>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</sub>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubscriptNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'subscript'}-sub`
}
}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-superscript-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<sup>
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</sup>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SuperscriptNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'superscript'}-sup`
}
}
@@ -0,0 +1,195 @@
import type { ElementRef, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, inject, Input, ViewChild } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList, getString } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-table-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<table #tableElement>
<colgroup *ngIf="columnWidths.length > 0">
<col
*ngFor="let cell of headerCells; let cellIndex = index; trackBy: trackCell"
[style.width.px]="columnWidths[cellIndex] || null"
>
</colgroup>
<thead *ngIf="headerRow">
<tr>
<ng-container *ngFor="let cell of headerCells; let cellIndex = index; trackBy: trackCell">
<th [style.text-align]="alignment(cell) || null">
<markstream-angular-nested-renderer
[nodes]="childrenFor(cell)"
[context]="context"
[indexPrefix]="cellPrefix('head', cellIndex)"
/>
<button
*ngIf="cellIndex < headerCells.length - 1"
type="button"
class="table-node__resize-handle"
[attr.aria-label]="'Resize columns ' + (cellIndex + 1) + ' and ' + (cellIndex + 2)"
(pointerdown)="startColumnResize(cellIndex, $event)"
></button>
</th>
</ng-container>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of rows; let rowIndex = index; trackBy: trackRow">
<ng-container *ngFor="let cell of cellsFor(row); let cellIndex = index; trackBy: trackCell">
<td [style.text-align]="alignment(cell) || null">
<markstream-angular-nested-renderer
[nodes]="childrenFor(cell)"
[context]="context"
[indexPrefix]="cellPrefix(rowIndex, cellIndex)"
/>
</td>
</ng-container>
</tr>
</tbody>
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableNodeComponent implements OnDestroy {
@ViewChild('tableElement') tableElement?: ElementRef<HTMLTableElement>
private readonly cdr = inject(ChangeDetectorRef)
private currentHeaderCellCount = 0
private readonly minColumnWidth = 48
private resizeState: {
index: number
startX: number
startWidth: number
nextStartWidth: number
widths: number[]
} | null = null
columnWidths: number[] = []
private _node!: AngularRenderableNode
@Input({ required: true })
set node(value: AngularRenderableNode) {
this._node = value
const count = this.headerCells.length
if (count !== this.currentHeaderCellCount) {
this.stopColumnResize()
this.columnWidths = []
this.currentHeaderCellCount = count
}
}
get node() {
return this._node
}
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get headerRow() {
const row = (this.node as any)?.header
return row && typeof row === 'object' ? row as AngularRenderableNode : null
}
get headerCells() {
return this.headerRow ? this.cellsFor(this.headerRow) : []
}
get rows() {
return getNodeList((this.node as any)?.rows)
}
cellsFor(row: AngularRenderableNode) {
return getNodeList((row as any)?.cells)
}
childrenFor(cell: AngularRenderableNode) {
return getNodeList((cell as any)?.children)
}
alignment(cell: AngularRenderableNode) {
return getString((cell as any)?.align)
}
trackRow = (index: number) => {
return `${this.indexKey || 'table'}-row-${index}`
}
trackCell = (index: number) => {
return index
}
cellPrefix(rowIndex: string | number, cellIndex: number) {
return `${this.indexKey || 'table'}-${rowIndex}-${cellIndex}`
}
startColumnResize(index: number, event: PointerEvent) {
if (event.button !== 0)
return
const widths = this.measureHeaderWidths()
const startWidth = widths[index]
const nextStartWidth = widths[index + 1]
if (!startWidth || !nextStartWidth)
return
event.preventDefault()
this.stopColumnResize()
this.resizeState = {
index,
startX: event.clientX,
startWidth,
nextStartWidth,
widths,
}
this.columnWidths = widths
this.cdr.markForCheck()
window.addEventListener('pointermove', this.onColumnResizeMove)
window.addEventListener('pointerup', this.stopColumnResize)
window.addEventListener('pointercancel', this.stopColumnResize)
}
ngOnDestroy() {
this.stopColumnResize()
}
private measureHeaderWidths() {
const cells = this.tableElement?.nativeElement.querySelectorAll('thead th')
return Array.from(cells ?? [], cell => Math.round(cell.getBoundingClientRect().width))
}
private onColumnResizeMove = (event: PointerEvent) => {
if (!this.resizeState)
return
event.preventDefault()
const pairWidth = this.resizeState.startWidth + this.resizeState.nextStartWidth
const minWidth = Math.min(this.minColumnWidth, Math.floor(pairWidth / 2))
const width = Math.max(
minWidth,
Math.min(pairWidth - minWidth, Math.round(this.resizeState.startWidth + event.clientX - this.resizeState.startX)),
)
const nextWidths = [...this.resizeState.widths]
nextWidths[this.resizeState.index] = width
nextWidths[this.resizeState.index + 1] = pairWidth - width
this.columnWidths = nextWidths
this.cdr.markForCheck()
}
private stopColumnResize = () => {
if (!this.resizeState)
return
window.removeEventListener('pointermove', this.onColumnResizeMove)
window.removeEventListener('pointerup', this.stopColumnResize)
window.removeEventListener('pointercancel', this.stopColumnResize)
this.resizeState = null
}
}
@@ -0,0 +1,121 @@
import type { OnChanges } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { getString } from '../shared/node-helpers'
import { resolveStreamingTextState } from './streamingTextState'
@Component({
selector: 'markstream-angular-text-node',
standalone: true,
imports: [CommonModule],
template: `
<ng-container *ngIf="!centered; else centeredText">
<span class="markstream-angular-text-node">
<span *ngIf="settledText">{{ settledText }}</span>
<span
*ngIf="streamedDelta"
[class]="'markstream-angular-text__stream-delta ' + streamedDeltaClass"
(animationend)="settleStreamedDelta()"
>{{ streamedDelta }}</span>
</span>
</ng-container>
<ng-template #centeredText>
<span class="markstream-angular-text-node markstream-angular-text--centered">
<span *ngIf="settledText">{{ settledText }}</span>
<span
*ngIf="streamedDelta"
[class]="'markstream-angular-text__stream-delta ' + streamedDeltaClass"
(animationend)="settleStreamedDelta()"
>{{ streamedDelta }}</span>
</span>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TextNodeComponent implements OnChanges {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
@Input() typewriter?: boolean
settledText = ''
streamedDelta = ''
private streamFadeVersion = 0
private lastStreamRenderVersion?: number
ngOnChanges() {
const nextText = getString((this.node as any)?.content)
const streamStateKey = getString(this.indexKey).trim()
const textStreamState = this.context?.textStreamState
const streamRenderVersion = this.context?.streamRenderVersion
const streamRenderVersionChanged = streamRenderVersion !== this.lastStreamRenderVersion
const rendered = `${this.settledText}${this.streamedDelta}`
const previousPersisted = streamStateKey
? textStreamState?.get(streamStateKey)
: undefined
const previousText = previousPersisted ?? rendered
if (!this.typewriterEnabled) {
this.settledText = nextText
this.streamedDelta = ''
if (streamStateKey)
textStreamState?.set(streamStateKey, nextText)
this.lastStreamRenderVersion = streamRenderVersion
return
}
if (nextText === previousText) {
if (this.streamedDelta && streamRenderVersionChanged) {
this.settleStreamedDelta()
}
else if (rendered !== nextText) {
this.settledText = nextText
this.streamedDelta = ''
}
if (streamStateKey)
textStreamState?.set(streamStateKey, nextText)
this.lastStreamRenderVersion = streamRenderVersion
return
}
const nextState = resolveStreamingTextState({
nextContent: nextText,
previousContent: previousText,
typewriterEnabled: this.typewriterEnabled,
})
this.settledText = nextState.settledContent
this.streamedDelta = nextState.streamedDelta
if (nextState.appended)
this.streamFadeVersion += 1
if (streamStateKey)
textStreamState?.set(streamStateKey, nextText)
this.lastStreamRenderVersion = streamRenderVersion
}
get centered() {
return !!(this.node as any)?.center
}
get streamedDeltaClass() {
return this.streamFadeVersion % 2 === 0
? 'markstream-angular-text__stream-delta--a'
: 'markstream-angular-text__stream-delta--b'
}
get typewriterEnabled() {
if (typeof this.typewriter === 'boolean')
return this.typewriter
if (typeof this.context?.typewriter === 'boolean')
return this.context.typewriter
return true
}
settleStreamedDelta() {
if (!this.streamedDelta)
return
this.settledText = `${this.settledText}${this.streamedDelta}`
this.streamedDelta = ''
}
}
@@ -0,0 +1,47 @@
export interface ResolveStreamingTextStateOptions {
nextContent: string
previousContent: string
typewriterEnabled: boolean
}
export interface StreamingTextStateResult {
settledContent: string
streamedDelta: string
appended: boolean
}
export function resolveStreamingTextState({
nextContent,
previousContent,
typewriterEnabled,
}: ResolveStreamingTextStateOptions): StreamingTextStateResult {
if (!typewriterEnabled) {
return {
settledContent: nextContent,
streamedDelta: '',
appended: false,
}
}
if (nextContent === previousContent) {
return {
settledContent: nextContent,
streamedDelta: '',
appended: false,
}
}
if (previousContent && nextContent.startsWith(previousContent) && nextContent.length > previousContent.length) {
return {
settledContent: previousContent,
streamedDelta: nextContent.slice(previousContent.length),
appended: true,
}
}
return {
settledContent: nextContent,
streamedDelta: '',
appended: false,
}
}
@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'markstream-angular-thematic-break-node',
standalone: true,
template: '<hr>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ThematicBreakNodeComponent {}
@@ -0,0 +1,34 @@
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'
import { NestedRendererComponent } from '../NestedRenderer/NestedRenderer.component'
import { getNodeList } from '../shared/node-helpers'
@Component({
selector: 'markstream-angular-vmr-container-node',
standalone: true,
imports: [CommonModule, forwardRef(() => NestedRendererComponent)],
template: `
<div class="markstream-angular-vmr-container">
<markstream-angular-nested-renderer
[nodes]="children"
[context]="context"
[indexPrefix]="nestedPrefix"
/>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VmrContainerNodeComponent {
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() indexKey?: string
get children() {
return getNodeList((this.node as any)?.children)
}
get nestedPrefix() {
return `${this.indexKey || 'vmr'}-container`
}
}
@@ -0,0 +1,63 @@
export const MERMAID_PREVIEW_MIN_HEIGHT = 360
export const MERMAID_PREVIEW_MAX_HEIGHT = 500
export const INFOGRAPHIC_PREVIEW_MIN_HEIGHT = 360
export const INFOGRAPHIC_PREVIEW_MAX_HEIGHT = 500
export function parsePositiveNumber(value: unknown) {
const numeric = typeof value === 'number' ? value : Number.parseFloat(String(value ?? ''))
return Number.isFinite(numeric) && numeric > 0 ? numeric : null
}
export function getMermaidDiagramKind(code: string) {
for (const rawLine of code.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('%%'))
continue
const match = line.match(/^([A-Z][\w-]*)\b/i)
return match?.[1]?.toLowerCase() || ''
}
return ''
}
export function estimateMermaidPreviewHeight(code: string) {
const meaningfulLines = code
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('%%'))
const lineCount = Math.max(1, meaningfulLines.length)
const kind = getMermaidDiagramKind(code)
if (kind === 'gantt')
return 220 + lineCount * 28
if (kind === 'sequencediagram')
return 180 + lineCount * 26
if (kind === 'classdiagram' || kind === 'statediagram' || kind === 'erdiagram')
return 180 + lineCount * 24
if (kind === 'flowchart' || kind === 'graph')
return 170 + lineCount * 28
return 200 + lineCount * 22
}
export function estimateInfographicPreviewHeight(code: string) {
const itemCount = code
.split(/\r?\n/)
.filter(line => /^\s*-\s+/.test(line))
.length
if (itemCount >= 3)
return INFOGRAPHIC_PREVIEW_MAX_HEIGHT
if (itemCount > 0)
return 280 + itemCount * 60
return INFOGRAPHIC_PREVIEW_MIN_HEIGHT
}
export function clampPreviewHeight(
height: number,
minHeight = MERMAID_PREVIEW_MIN_HEIGHT,
maxHeight: number | null = MERMAID_PREVIEW_MAX_HEIGHT,
) {
return maxHeight == null
? Math.max(minHeight, height)
: Math.min(Math.max(minHeight, height), maxHeight)
}
@@ -0,0 +1,363 @@
import type { Type } from '@angular/core'
import type { BaseNode, MarkdownIt, ParsedNode, ParseOptions } from 'stream-markdown-parser'
import type { CodeBlockMonacoOptions, CodeBlockMonacoTheme } from '../../types/monaco'
import {
getHtmlTagFromContent,
getMarkdown,
hasCompleteHtmlTagContent,
normalizeCustomHtmlTags,
normalizeCustomHtmlTagName as normalizeTagName,
parseMarkdownToStructure,
stripCustomHtmlWrapper,
} from 'stream-markdown-parser'
import { hydrateCustomTagContent } from '../../hydrateCustomTagContent'
export {
getHtmlTagFromContent,
hasCompleteHtmlTagContent,
normalizeCustomHtmlTags,
normalizeTagName,
stripCustomHtmlWrapper,
}
export type AngularRenderableNode = (ParsedNode | BaseNode) & Record<string, unknown>
export interface NodeRendererEvents {
onCopy?: (code: string) => void
onHandleArtifactClick?: (payload: any) => void
}
export interface NodeRendererProps {
content?: string
nodes?: readonly BaseNode[] | null
final?: boolean
parseOptions?: ParseOptions
customMarkdownIt?: (md: MarkdownIt) => MarkdownIt
debugPerformance?: boolean
customHtmlTags?: readonly string[]
viewportPriority?: boolean
codeBlockStream?: boolean
codeBlockDarkTheme?: CodeBlockMonacoTheme
codeBlockLightTheme?: CodeBlockMonacoTheme
codeBlockMonacoOptions?: CodeBlockMonacoOptions
renderCodeBlocksAsPre?: boolean
codeBlockMinWidth?: string | number
codeBlockMaxWidth?: string | number
codeBlockProps?: Record<string, any>
mermaidProps?: Record<string, any>
d2Props?: Record<string, any>
infographicProps?: Record<string, any>
customComponents?: Record<string, Type<any>>
showTooltips?: boolean
themes?: CodeBlockMonacoTheme[]
isDark?: boolean
customId?: string
indexKey?: number | string
typewriter?: boolean
batchRendering?: boolean
initialRenderBatchSize?: number
renderBatchSize?: number
renderBatchDelay?: number
renderBatchBudgetMs?: number
renderBatchIdleTimeoutMs?: number
deferNodesUntilVisible?: boolean
maxLiveNodes?: number
liveNodeBuffer?: number
allowHtml?: boolean
}
export interface AngularRenderContext {
customId?: string
isDark?: boolean
indexKey?: string
final?: boolean
typewriter?: boolean
textStreamState?: Map<string, string>
streamRenderVersion?: number
showTooltips?: boolean
codeBlockStream?: boolean
renderCodeBlocksAsPre?: boolean
allowHtml?: boolean
customHtmlTags?: readonly string[]
parseOptions?: ParseOptions
customMarkdownIt?: (md: MarkdownIt) => MarkdownIt
codeBlockProps?: Record<string, any>
mermaidProps?: Record<string, any>
d2Props?: Record<string, any>
infographicProps?: Record<string, any>
customComponents?: Record<string, Type<any>>
codeBlockThemes?: {
themes?: CodeBlockMonacoTheme[]
darkTheme?: CodeBlockMonacoTheme
lightTheme?: CodeBlockMonacoTheme
monacoOptions?: CodeBlockMonacoOptions
minWidth?: string | number
maxWidth?: string | number
}
events: NodeRendererEvents
}
const markdownCache = new Map<string, MarkdownIt>()
export const BLOCK_LEVEL_TYPES = new Set([
'table',
'code_block',
'html_block',
'blockquote',
'list',
'list_item',
'definition_list',
'footnote',
'footnote_reference',
'footnote_anchor',
'admonition',
'thematic_break',
'math_block',
'thinking',
'vmr_container',
])
export function buildRenderContext(
props: NodeRendererProps,
events: NodeRendererEvents = {},
textStreamState?: Map<string, string>,
streamRenderVersion?: number,
): AngularRenderContext {
const customHtmlTags = normalizeCustomHtmlTags([
...(props.customHtmlTags || []),
...((((props.parseOptions as any)?.customHtmlTags) || []) as string[]),
])
return {
customId: props.customId,
isDark: props.isDark,
indexKey: props.indexKey != null ? String(props.indexKey) : undefined,
final: props.final,
typewriter: props.typewriter,
textStreamState,
streamRenderVersion,
showTooltips: props.showTooltips,
codeBlockStream: props.codeBlockStream,
renderCodeBlocksAsPre: props.renderCodeBlocksAsPre,
allowHtml: props.allowHtml !== false,
customHtmlTags,
parseOptions: props.parseOptions,
customMarkdownIt: props.customMarkdownIt,
codeBlockProps: props.codeBlockProps,
mermaidProps: props.mermaidProps,
d2Props: props.d2Props,
infographicProps: props.infographicProps,
customComponents: props.customComponents,
codeBlockThemes: {
themes: props.themes,
darkTheme: props.codeBlockDarkTheme,
lightTheme: props.codeBlockLightTheme,
monacoOptions: props.codeBlockMonacoOptions,
minWidth: props.codeBlockMinWidth,
maxWidth: props.codeBlockMaxWidth,
},
events,
}
}
export function resolveParsedNodes(props: NodeRendererProps): AngularRenderableNode[] {
if (Array.isArray(props.nodes))
return props.nodes as AngularRenderableNode[]
const content = getString(props.content)
if (!content)
return []
const normalizedTags = normalizeCustomHtmlTags([
...(props.customHtmlTags || []),
...((((props.parseOptions as any)?.customHtmlTags) || []) as string[]),
])
const cacheKey = `${props.customId || 'markstream-angular'}::${normalizedTags.join(',')}`
let markdown = markdownCache.get(cacheKey)
if (!markdown) {
markdown = getMarkdown(cacheKey, { customHtmlTags: normalizedTags })
markdownCache.set(cacheKey, markdown)
}
const parser = props.customMarkdownIt
? props.customMarkdownIt(markdown)
: markdown
const options: ParseOptions = {
...(props.parseOptions ?? {}),
}
if (typeof props.final === 'boolean')
options.final = props.final
if (normalizedTags.length > 0)
(options as any).customHtmlTags = normalizedTags
return hydrateCustomTagContent(
parseMarkdownToStructure(content, parser, options) as AngularRenderableNode[],
content,
normalizedTags,
) as AngularRenderableNode[]
}
export function getNodeList(value: unknown): AngularRenderableNode[] {
return Array.isArray(value)
? value.filter((item): item is AngularRenderableNode => !!item && typeof item === 'object')
: []
}
export function isWhitespaceTextNode(node: AngularRenderableNode | null | undefined) {
return getString((node as any)?.type) === 'text' && getString((node as any)?.content).trim() === ''
}
export function getMeaningfulLinkChildren(node: AngularRenderableNode | null | undefined) {
if (getString((node as any)?.type) !== 'link')
return []
return getNodeList((node as any)?.children).filter(child => !isWhitespaceTextNode(child))
}
export function isImageOnlyLinkNode(node: AngularRenderableNode | null | undefined) {
const linkChildren = getMeaningfulLinkChildren(node)
return linkChildren.length === 1 && getString((linkChildren[0] as any)?.type) === 'image'
}
export function isMediaOnlyParagraphNodes(children: readonly AngularRenderableNode[]) {
const meaningfulChildren = getNodeList(children).filter(child => !isWhitespaceTextNode(child))
return meaningfulChildren.length > 0
&& meaningfulChildren.every(child => getString((child as any)?.type) === 'image' || isImageOnlyLinkNode(child))
}
export function normalizeMediaOnlyParagraphNodes(children: readonly AngularRenderableNode[]) {
const source = getNodeList(children)
const meaningfulChildren = source.filter(child => !isWhitespaceTextNode(child))
if (!isMediaOnlyParagraphNodes(source) || meaningfulChildren.length <= 1)
return source
const normalized: AngularRenderableNode[] = []
for (let index = 0; index < source.length; index += 1) {
const child = source[index]
if (!isWhitespaceTextNode(child)) {
normalized.push(child)
continue
}
const hasPrevious = normalized.length > 0
const hasNext = source.slice(index + 1).some(nextChild => !isWhitespaceTextNode(nextChild))
if (!hasPrevious || !hasNext)
continue
normalized.push({
...(child as Record<string, unknown>),
content: ' ',
raw: ' ',
} as AngularRenderableNode)
}
return normalized
}
export function getString(value: unknown): string {
return typeof value === 'string'
? value
: value == null
? ''
: String(value)
}
export function isSafeAttrName(value: string): boolean {
return /^[^\s"'<>`=]+$/.test(value) && !/^on/i.test(value)
}
export function escapeHtml(value: unknown): string {
return getString(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export function escapeAttr(value: unknown): string {
return escapeHtml(value).replace(/`/g, '&#96;')
}
export function sanitizeClassToken(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
}
export function clampHeadingLevel(value: unknown): number {
const level = Math.trunc(Number(value) || 1)
return Math.min(6, Math.max(1, level))
}
export function capitalize(value: string): string {
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : ''
}
export function normalizeCodeLanguage(raw: unknown) {
const head = String(String(raw ?? '').split(/\s+/g)[0] ?? '').toLowerCase()
const safe = head.replace(/[^\w-]/g, '')
return safe || 'plaintext'
}
export function resolveCodeBlockLanguage(node: AngularRenderableNode) {
return normalizeCodeLanguage((node as any)?.language)
}
export function encodeDataPayload(value: string) {
if (!value)
return ''
const globalBuffer = (globalThis as any)?.require?.('buffer')?.Buffer
if (globalBuffer?.from)
return globalBuffer.from(value, 'utf8').toString('base64')
if (typeof TextEncoder !== 'undefined' && typeof globalThis.btoa === 'function') {
const bytes = new TextEncoder().encode(value)
let binary = ''
for (const byte of bytes)
binary += String.fromCharCode(byte)
return globalThis.btoa(binary)
}
return ''
}
export function normalizeTokenAttrs(attrs?: Array<[string, string | null]> | null) {
if (!Array.isArray(attrs) || attrs.length === 0)
return null
return attrs.reduce<Record<string, string | true>>((acc, [name, value]) => {
if (!name || !isSafeAttrName(name))
return acc
acc[name] = value ?? true
return acc
}, {})
}
export function splitParagraphChildren(children: readonly AngularRenderableNode[]) {
const parts: Array<
| { kind: 'inline', nodes: AngularRenderableNode[] }
| { kind: 'block', node: AngularRenderableNode }
> = []
const inlineBuffer: AngularRenderableNode[] = []
const flushInline = () => {
if (!inlineBuffer.length)
return
parts.push({ kind: 'inline', nodes: inlineBuffer.slice() })
inlineBuffer.length = 0
}
for (const child of children) {
if (BLOCK_LEVEL_TYPES.has(String(child?.type || ''))) {
flushInline()
parts.push({ kind: 'block', node: child })
}
else {
inlineBuffer.push(child)
}
}
flushInline()
return parts
}
@@ -0,0 +1,126 @@
import type { AngularRenderableNode, AngularRenderContext } from './node-helpers'
import { clampPreviewHeight, estimateInfographicPreviewHeight, estimateMermaidPreviewHeight, parsePositiveNumber } from './diagram-height'
import { getHtmlTagFromContent, resolveCodeBlockLanguage, stripCustomHtmlWrapper } from './node-helpers'
export type CodeBlockMode = 'mermaid' | 'd2' | 'infographic' | 'pre' | 'code'
export function resolveNodeOutletCodeMode(
node: AngularRenderableNode,
context?: AngularRenderContext,
): CodeBlockMode {
if (context?.renderCodeBlocksAsPre)
return 'pre'
const language = resolveCodeBlockLanguage(node)
if (language === 'd2' || language === 'd2lang')
return 'd2'
if (language === 'infographic')
return 'infographic'
if (language === 'mermaid')
return 'mermaid'
return 'code'
}
export function resolveHtmlTag(node: AngularRenderableNode) {
return String((node as any)?.tag || '').trim().toLowerCase() || getHtmlTagFromContent((node as any)?.content)
}
export function coerceCustomHtmlNode(node: AngularRenderableNode) {
const tag = resolveHtmlTag(node)
if (!tag)
return node
return {
...(node as any),
type: tag,
tag,
content: stripCustomHtmlWrapper((node as any)?.content, tag),
} as AngularRenderableNode
}
export function coerceBuiltinHtmlNode(node: AngularRenderableNode, resolvedType: string) {
const tag = resolveHtmlTag(node)
if (!tag)
return node
return {
...(node as any),
type: resolvedType,
tag,
} as AngularRenderableNode
}
export function resolveNodeOutletCustomInputs(
node: AngularRenderableNode,
context?: AngularRenderContext,
) {
if (String((node as any)?.type || '') !== 'code_block')
return null
const codeMode = resolveNodeOutletCodeMode(node, context)
if (codeMode === 'mermaid') {
return withEstimatedPreviewHeight(
context?.mermaidProps,
estimateMermaidPreviewHeight(getNodeCode(node)),
)
}
if (codeMode === 'd2')
return context?.d2Props ?? null
if (codeMode === 'infographic') {
return withEstimatedPreviewHeight(
context?.infographicProps,
estimateInfographicPreviewHeight(getNodeCode(node)),
)
}
return context?.codeBlockProps ?? null
}
function getNodeCode(node: AngularRenderableNode) {
return String((node as any)?.code ?? '')
}
function withEstimatedPreviewHeight(props: Record<string, any> | null | undefined, estimatedHeight: number) {
const next = { ...(props || {}) }
if (parsePositiveNumber(next.estimatedPreviewHeightPx) == null) {
next.estimatedPreviewHeightPx = clampPreviewHeight(
estimatedHeight,
undefined,
next.maxHeight === 'none' ? null : (parsePositiveNumber(next.maxHeight) ?? undefined),
)
}
return next
}
export function resolveNodeOutletCustomComponent(
node: AngularRenderableNode,
context?: AngularRenderContext,
customComponents?: Record<string, any> | null,
) {
const mapping = customComponents ?? context?.customComponents ?? null
const resolvedType = String((node as any)?.type || '')
if (resolvedType === 'code_block') {
const language = resolveCodeBlockLanguage(node)
const customForLanguage = language ? mapping?.[language] : null
if (customForLanguage)
return customForLanguage
const codeMode = resolveNodeOutletCodeMode(node, context)
if (codeMode === 'mermaid' && mapping?.mermaid)
return mapping.mermaid
if (codeMode === 'd2' && mapping?.d2)
return mapping.d2
if (codeMode === 'infographic' && mapping?.infographic)
return mapping.infographic
if (mapping?.code_block)
return mapping.code_block
}
const direct = mapping?.[resolvedType]
if (direct)
return direct
const tag = resolveHtmlTag(node)
if (tag && mapping?.[tag])
return mapping[tag]
return null
}
@@ -0,0 +1,99 @@
export const DEFAULT_NODE_HEIGHT = 32
export const MAX_DEFERRED_NODE_COUNT = 900
export interface LiveRange {
start: number
end: number
}
export interface DeferNodesOptions {
deferNodesUntilVisible?: boolean
maxLiveNodes?: number
parsedNodeCount: number
viewportPriority?: boolean
virtualizationEnabled: boolean
}
export function resolveVirtualizationEnabled(parsedNodeCount: number, maxLiveNodes?: number) {
const configuredMaxLiveNodes = Math.trunc(maxLiveNodes ?? 320)
if (!Number.isFinite(configuredMaxLiveNodes) || configuredMaxLiveNodes <= 0)
return false
const resolvedMaxLiveNodes = Math.max(1, configuredMaxLiveNodes)
return parsedNodeCount > resolvedMaxLiveNodes
}
export function resolveDeferNodes(options: DeferNodesOptions) {
if (options.deferNodesUntilVisible === false)
return false
const configuredMaxLiveNodes = Math.trunc(options.maxLiveNodes ?? 320)
if (!Number.isFinite(configuredMaxLiveNodes) || configuredMaxLiveNodes <= 0)
return false
if (options.virtualizationEnabled)
return false
if (options.parsedNodeCount > MAX_DEFERRED_NODE_COUNT)
return false
return options.viewportPriority !== false
}
export function resolveAverageNodeHeight(
nodeHeights: ReadonlyMap<number, number>,
fallback = DEFAULT_NODE_HEIGHT,
) {
if (!nodeHeights.size)
return fallback
let total = 0
for (const height of nodeHeights.values())
total += height
return Math.max(16, total / nodeHeights.size)
}
export function estimateHeightRange(
start: number,
end: number,
nodeHeights: ReadonlyMap<number, number>,
averageNodeHeight: number,
) {
if (start >= end)
return 0
let total = 0
for (let index = start; index < end; index += 1)
total += nodeHeights.get(index) ?? averageNodeHeight
return total
}
export function computeLiveRange(
total: number,
focusIndex: number,
maxLiveNodes?: number,
liveNodeBuffer?: number,
): LiveRange {
if (!total)
return { start: 0, end: 0 }
const resolvedMaxLiveNodes = Math.max(1, Math.trunc(maxLiveNodes ?? 320))
const resolvedBuffer = Math.max(0, Math.trunc(liveNodeBuffer ?? 60))
const focus = Math.max(0, Math.min(Math.trunc(focusIndex), total - 1))
let start = Math.max(0, focus - resolvedBuffer)
let end = Math.min(total, focus + resolvedBuffer + 1)
const size = end - start
if (size > resolvedMaxLiveNodes) {
const excess = size - resolvedMaxLiveNodes
start += Math.ceil(excess / 2)
end -= Math.floor(excess / 2)
}
else if (size < resolvedMaxLiveNodes) {
const missing = resolvedMaxLiveNodes - size
start = Math.max(0, start - Math.ceil(missing / 2))
end = Math.min(total, end + Math.floor(missing / 2))
}
return {
start: Math.max(0, Math.min(start, total)),
end: Math.max(0, Math.min(Math.max(end, start), total)),
}
}
@@ -0,0 +1,85 @@
export async function copyTextToClipboard(source: string) {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(source)
return true
}
}
catch {
// Fall back to the textarea path below.
}
if (typeof document === 'undefined')
return false
const textarea = document.createElement('textarea')
textarea.value = source
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
return true
}
catch {
return false
}
finally {
textarea.remove()
}
}
export function clearElement(target: HTMLElement | null | undefined) {
if (!target)
return
try {
target.replaceChildren()
}
catch {
target.innerHTML = ''
}
}
export function setElementHtml(target: HTMLElement | null | undefined, html: string) {
if (!target)
return
clearElement(target)
if (!html)
return
try {
target.insertAdjacentHTML('afterbegin', html)
}
catch {
target.innerHTML = html
}
}
export function downloadSvgMarkup(svgMarkup: string, filename: string) {
if (!svgMarkup || typeof document === 'undefined' || typeof URL === 'undefined')
return
const blob = new Blob([svgMarkup], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
export function resolveCssSize(value: unknown, fallback?: string) {
if (value == null || value === '')
return fallback ?? null
return typeof value === 'number' ? `${value}px` : String(value)
}
export function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
@@ -0,0 +1,67 @@
import type { OnChanges } from '@angular/core'
import { Directive, ElementRef, inject, Input, Renderer2 } from '@angular/core'
import { isSafeAttrName } from './node-helpers'
@Directive({
selector: '[markstreamSafeAttrs]',
standalone: true,
})
export class SafeAttrsDirective implements OnChanges {
private readonly elementRef = inject(ElementRef<HTMLElement>)
private readonly renderer = inject(Renderer2)
private readonly appliedAttrs = new Set<string>()
private readonly appliedClasses = new Set<string>()
private readonly appliedStyles = new Set<string>()
@Input() markstreamSafeAttrs?: Record<string, unknown> | null
ngOnChanges() {
this.clearAppliedState()
const attrs = this.markstreamSafeAttrs
if (!attrs)
return
for (const [name, rawValue] of Object.entries(attrs)) {
if (!isSafeAttrName(name))
continue
if (name === 'class' || name === 'className') {
for (const token of String(rawValue || '').split(/\s+/g).map(item => item.trim()).filter(Boolean)) {
this.renderer.addClass(this.elementRef.nativeElement, token)
this.appliedClasses.add(token)
}
continue
}
if (name === 'style' && rawValue && typeof rawValue === 'object') {
for (const [styleName, styleValue] of Object.entries(rawValue as Record<string, unknown>)) {
this.renderer.setStyle(this.elementRef.nativeElement, styleName, styleValue as any)
this.appliedStyles.add(styleName)
}
continue
}
if (rawValue === false || rawValue == null)
continue
this.renderer.setAttribute(
this.elementRef.nativeElement,
name,
rawValue === true ? '' : String(rawValue),
)
this.appliedAttrs.add(name)
}
}
private clearAppliedState() {
for (const name of this.appliedAttrs)
this.renderer.removeAttribute(this.elementRef.nativeElement, name)
for (const token of this.appliedClasses)
this.renderer.removeClass(this.elementRef.nativeElement, token)
for (const name of this.appliedStyles)
this.renderer.removeStyle(this.elementRef.nativeElement, name)
this.appliedAttrs.clear()
this.appliedClasses.clear()
this.appliedStyles.clear()
}
}