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:
@@ -0,0 +1,108 @@
|
||||
# markstream-angular
|
||||
|
||||
Angular version of Markstream: a streaming-friendly Markdown renderer with KaTeX, Mermaid, Monaco code blocks, D2, infographic blocks, custom HTML tags, and cross-framework playground parity.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add markstream-angular @angular/core @angular/common
|
||||
```
|
||||
|
||||
Optional peer dependencies:
|
||||
|
||||
- `stream-monaco` for Monaco-powered code blocks
|
||||
- `mermaid` for Mermaid diagrams
|
||||
- `katex` for math rendering
|
||||
- `@terrastruct/d2` for D2 diagrams
|
||||
- `@antv/infographic` for infographic blocks
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
pnpm add stream-monaco mermaid katex @terrastruct/d2 @antv/infographic
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
Import the stylesheet once in your Angular app entry:
|
||||
|
||||
```ts
|
||||
import 'markstream-angular/index.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
```
|
||||
|
||||
Use the standalone component directly:
|
||||
|
||||
```ts
|
||||
import { Component, signal } from '@angular/core'
|
||||
import { bootstrapApplication } from '@angular/platform-browser'
|
||||
import { MarkstreamAngularComponent } from 'markstream-angular'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [MarkstreamAngularComponent],
|
||||
template: `
|
||||
<markstream-angular
|
||||
[content]="markdown()"
|
||||
[final]="true"
|
||||
[codeBlockStream]="true"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
class AppComponent {
|
||||
readonly markdown = signal(`# Hello Angular
|
||||
|
||||
- streaming markdown
|
||||
- code blocks
|
||||
- Mermaid / KaTeX / D2`)
|
||||
}
|
||||
|
||||
bootstrapApplication(AppComponent)
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
`markstream-angular` exports the same public props/context helpers you use at runtime:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AngularRenderContext,
|
||||
CodeBlockMonacoOptions,
|
||||
CustomComponentMap,
|
||||
MarkstreamAngularComponentProps,
|
||||
NodeRendererProps,
|
||||
} from 'markstream-angular'
|
||||
```
|
||||
|
||||
## Workers
|
||||
|
||||
KaTeX and Mermaid can use the same off-thread worker path as the React/Vue packages:
|
||||
|
||||
```ts
|
||||
import { setKaTeXWorker, setMermaidWorker } from 'markstream-angular'
|
||||
import KatexWorker from 'markstream-angular/workers/katexRenderer.worker?worker'
|
||||
import MermaidWorker from 'markstream-angular/workers/mermaidParser.worker?worker'
|
||||
|
||||
setKaTeXWorker(new KatexWorker())
|
||||
setMermaidWorker(new MermaidWorker())
|
||||
```
|
||||
|
||||
## Playground
|
||||
|
||||
In this monorepo:
|
||||
|
||||
- Angular playground: `pnpm play:angular`
|
||||
- Angular regression lab: `http://127.0.0.1:4175/test`
|
||||
- Angular version sandbox: `http://127.0.0.1:4175/test-sandbox`
|
||||
|
||||
## Status
|
||||
|
||||
This package is still tagged `alpha`, but the current direction is already aligned with `markstream-react` / `markstream-vue2` for:
|
||||
|
||||
- node-component renderer structure
|
||||
- streaming code block behavior
|
||||
- shared `/test` fixtures and cross-framework comparison
|
||||
- KaTeX / Mermaid worker integration
|
||||
|
||||
Issue tracker and source: [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue)
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "markstream-angular",
|
||||
"type": "module",
|
||||
"version": "0.0.2-beta.7",
|
||||
"description": "Experimental Angular Markdown renderer for Markstream, built on stream-markdown AST and a stable HTML baseline renderer.",
|
||||
"author": "Simon He",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/Simon-He95/markstream-vue#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Simon-He95/markstream-vue.git",
|
||||
"directory": "packages/markstream-angular"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Simon-He95/markstream-vue/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"angular",
|
||||
"markdown",
|
||||
"markdown-renderer",
|
||||
"streaming-markdown",
|
||||
"markstream",
|
||||
"markstream-angular"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./index.css": "./dist/index.css",
|
||||
"./workers/katexRenderer.worker": "./dist/workers/katexRenderer.worker.js",
|
||||
"./workers/mermaidParser.worker": "./dist/workers/mermaidParser.worker.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build --mode npm",
|
||||
"dev": "vite dev",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "pnpm run build",
|
||||
"release": "bumpp --commit --no-tag --no-push && pnpm publish --access public && node ../../scripts/tag-package.mjs --package-json package.json --push"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=20",
|
||||
"@angular/core": ">=20",
|
||||
"@antv/infographic": "^0.2.3",
|
||||
"@terrastruct/d2": ">=0.1.33",
|
||||
"katex": ">=0.16.22",
|
||||
"mermaid": ">=11",
|
||||
"stream-monaco": ">=0.0.40"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@antv/infographic": {
|
||||
"optional": true
|
||||
},
|
||||
"@terrastruct/d2": {
|
||||
"optional": true
|
||||
},
|
||||
"katex": {
|
||||
"optional": true
|
||||
},
|
||||
"mermaid": {
|
||||
"optional": true
|
||||
},
|
||||
"stream-monaco": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"stream-markdown-parser": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.19.130",
|
||||
"rollup": "^3.30.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-dts": "^4.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+49
@@ -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`
|
||||
}
|
||||
}
|
||||
+46
@@ -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`
|
||||
}
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
}
|
||||
+824
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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>`
|
||||
}
|
||||
}
|
||||
+383
@@ -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
|
||||
}
|
||||
+57
@@ -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}`
|
||||
}
|
||||
}
|
||||
+114
@@ -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)
|
||||
}
|
||||
}
|
||||
+17
@@ -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)
|
||||
}
|
||||
}
|
||||
+34
@@ -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`
|
||||
}
|
||||
}
|
||||
+79
@@ -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
|
||||
}
|
||||
}
|
||||
+33
@@ -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}`
|
||||
}
|
||||
}
|
||||
+38
@@ -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`
|
||||
}
|
||||
}
|
||||
+25
@@ -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}`
|
||||
}
|
||||
}
|
||||
+9
@@ -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 {}
|
||||
+55
@@ -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}`
|
||||
}
|
||||
}
|
||||
+34
@@ -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`
|
||||
}
|
||||
}
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
+50
@@ -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)
|
||||
}
|
||||
}
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
+487
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
+78
@@ -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 = ''
|
||||
}
|
||||
}
|
||||
+34
@@ -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`
|
||||
}
|
||||
}
|
||||
+156
@@ -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)
|
||||
}
|
||||
}
|
||||
+35
@@ -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`
|
||||
}
|
||||
}
|
||||
+59
@@ -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}`
|
||||
}
|
||||
}
|
||||
+126
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+761
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
+141
@@ -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)
|
||||
}
|
||||
}
|
||||
+282
@@ -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)
|
||||
}
|
||||
}
|
||||
+762
@@ -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)
|
||||
}
|
||||
}
|
||||
+125
@@ -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)
|
||||
}
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
}
|
||||
+17
@@ -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)
|
||||
}
|
||||
}
|
||||
+34
@@ -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`
|
||||
}
|
||||
}
|
||||
+34
@@ -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`
|
||||
}
|
||||
}
|
||||
+34
@@ -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`
|
||||
}
|
||||
}
|
||||
+34
@@ -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`
|
||||
}
|
||||
}
|
||||
+195
@@ -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
|
||||
}
|
||||
}
|
||||
+121
@@ -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 = ''
|
||||
}
|
||||
}
|
||||
+47
@@ -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,
|
||||
}
|
||||
}
|
||||
+9
@@ -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 {}
|
||||
+34
@@ -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`
|
||||
}
|
||||
}
|
||||
+63
@@ -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)
|
||||
}
|
||||
+363
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export function escapeAttr(value: unknown): string {
|
||||
return escapeHtml(value).replace(/`/g, '`')
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+126
@@ -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
|
||||
}
|
||||
+99
@@ -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)),
|
||||
}
|
||||
}
|
||||
+85
@@ -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))
|
||||
}
|
||||
+67
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Type } from '@angular/core'
|
||||
|
||||
export type CustomComponentMap = Record<string, Type<any>>
|
||||
|
||||
const GLOBAL_KEY = '__global__'
|
||||
|
||||
interface Store {
|
||||
scopedComponents: Record<string, CustomComponentMap>
|
||||
revision: number
|
||||
listeners: Set<() => void>
|
||||
}
|
||||
|
||||
const STORE_KEY = '__MARKSTREAM_ANGULAR_CUSTOM_COMPONENTS_STORE__'
|
||||
const store: Store = (() => {
|
||||
const globalStore = globalThis as any
|
||||
if (globalStore[STORE_KEY])
|
||||
return globalStore[STORE_KEY] as Store
|
||||
|
||||
const next: Store = {
|
||||
scopedComponents: {},
|
||||
revision: 0,
|
||||
listeners: new Set(),
|
||||
}
|
||||
globalStore[STORE_KEY] = next
|
||||
return next
|
||||
})()
|
||||
|
||||
function bumpRevision() {
|
||||
store.revision += 1
|
||||
for (const listener of Array.from(store.listeners)) {
|
||||
try {
|
||||
listener()
|
||||
}
|
||||
catch {
|
||||
// Ignore subscriber failures so one bad consumer does not break the registry.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeCustomComponents(listener: () => void) {
|
||||
store.listeners.add(listener)
|
||||
return () => {
|
||||
store.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCustomComponentsRevision() {
|
||||
return store.revision
|
||||
}
|
||||
|
||||
export function setCustomComponents(id: string, mapping: CustomComponentMap): void
|
||||
export function setCustomComponents(mapping: CustomComponentMap): void
|
||||
export function setCustomComponents(idOrMapping: string | CustomComponentMap, maybeMapping?: CustomComponentMap) {
|
||||
if (typeof idOrMapping === 'string')
|
||||
store.scopedComponents[idOrMapping] = { ...(maybeMapping || {}) }
|
||||
else
|
||||
store.scopedComponents[GLOBAL_KEY] = { ...idOrMapping }
|
||||
bumpRevision()
|
||||
}
|
||||
|
||||
export function getCustomNodeComponents(customId?: string): CustomComponentMap {
|
||||
const globalMapping = store.scopedComponents[GLOBAL_KEY] || {}
|
||||
if (!customId)
|
||||
return globalMapping
|
||||
|
||||
const scopedMapping = store.scopedComponents[customId] || {}
|
||||
if (!globalMapping || Object.keys(globalMapping).length === 0)
|
||||
return scopedMapping
|
||||
if (!scopedMapping || Object.keys(scopedMapping).length === 0)
|
||||
return globalMapping
|
||||
|
||||
return {
|
||||
...globalMapping,
|
||||
...scopedMapping,
|
||||
}
|
||||
}
|
||||
|
||||
export function removeCustomComponents(id: string) {
|
||||
if (id === GLOBAL_KEY) {
|
||||
throw new Error('removeCustomComponents: cannot delete global mapping; call clearGlobalCustomComponents instead.')
|
||||
}
|
||||
delete store.scopedComponents[id]
|
||||
bumpRevision()
|
||||
}
|
||||
|
||||
export function clearGlobalCustomComponents() {
|
||||
delete store.scopedComponents[GLOBAL_KEY]
|
||||
bumpRevision()
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
import { getD2 } from './optional/d2'
|
||||
import { getInfographic } from './optional/infographic'
|
||||
import { getKatex } from './optional/katex'
|
||||
import { getMermaid } from './optional/mermaid'
|
||||
import { getUseMonaco } from './optional/monaco'
|
||||
import { extractRenderedSvg, toSafeSvgMarkup } from './sanitizeSvg'
|
||||
import { normalizeKaTeXRenderInput } from './utils/normalizeKaTeXRenderInput'
|
||||
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from './workers/katexWorkerClient'
|
||||
import { canParseOffthread, findPrefixOffthread } from './workers/mermaidWorkerClient'
|
||||
|
||||
let mermaidRenderId = 0
|
||||
const rootHandles = new WeakMap<HTMLElement, RenderedHtmlEnhancementHandle>()
|
||||
type MermaidTheme = 'light' | 'dark'
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
const MONACO_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cjs: 'javascript',
|
||||
cts: 'typescript',
|
||||
d2lang: 'plaintext',
|
||||
js: 'javascript',
|
||||
jsonc: 'json',
|
||||
jsx: 'javascript',
|
||||
md: 'markdown',
|
||||
mjs: 'javascript',
|
||||
sh: 'shell',
|
||||
text: 'plaintext',
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
txt: 'plaintext',
|
||||
vue: 'html',
|
||||
yml: 'yaml',
|
||||
}
|
||||
|
||||
export interface EnhanceRenderedHtmlOptions {
|
||||
final?: boolean
|
||||
isDark?: boolean
|
||||
renderCodeBlocksAsPre?: boolean
|
||||
monacoOptions?: Record<string, any>
|
||||
d2ThemeId?: number | null
|
||||
d2DarkThemeId?: number | null
|
||||
showTooltips?: boolean
|
||||
codeBlockProps?: Record<string, any>
|
||||
mermaidProps?: Record<string, any>
|
||||
d2Props?: Record<string, any>
|
||||
infographicProps?: Record<string, any>
|
||||
onCopy?: (code: string) => void
|
||||
isCancelled?: () => boolean
|
||||
}
|
||||
|
||||
export interface RenderedHtmlEnhancementHandle {
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export async function enhanceRenderedHtml(
|
||||
root: HTMLElement,
|
||||
options: EnhanceRenderedHtmlOptions = {},
|
||||
): Promise<RenderedHtmlEnhancementHandle> {
|
||||
disposeRenderedHtmlEnhancements(root)
|
||||
|
||||
const cleanupFns: Array<() => void> = []
|
||||
let disposed = false
|
||||
const isActive = () => !disposed && options.isCancelled?.() !== true
|
||||
const handle: RenderedHtmlEnhancementHandle = {
|
||||
dispose: () => {
|
||||
if (disposed)
|
||||
return
|
||||
disposed = true
|
||||
for (let index = cleanupFns.length - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
cleanupFns[index]?.()
|
||||
}
|
||||
catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
if (rootHandles.get(root) === handle)
|
||||
rootHandles.delete(root)
|
||||
},
|
||||
}
|
||||
|
||||
rootHandles.set(root, handle)
|
||||
|
||||
if (!isActive())
|
||||
return handle
|
||||
|
||||
await renderKatex(root, isActive)
|
||||
if (!isActive())
|
||||
return handle
|
||||
|
||||
await renderMermaid(root, cleanupFns, options, isActive)
|
||||
if (!isActive())
|
||||
return handle
|
||||
|
||||
if (options.final !== false) {
|
||||
await renderInfographic(root, cleanupFns, options, isActive)
|
||||
if (!isActive())
|
||||
return handle
|
||||
|
||||
await renderD2(root, cleanupFns, options, isActive)
|
||||
if (!isActive())
|
||||
return handle
|
||||
|
||||
if (!options.renderCodeBlocksAsPre)
|
||||
await renderMonaco(root, cleanupFns, options, isActive)
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
export function disposeRenderedHtmlEnhancements(root: HTMLElement | null | undefined) {
|
||||
if (!root)
|
||||
return
|
||||
const existing = rootHandles.get(root)
|
||||
existing?.dispose()
|
||||
}
|
||||
|
||||
function readKatexSource(node: HTMLElement, selector: string) {
|
||||
const sourceElement = node.querySelector<HTMLElement>(selector)
|
||||
return {
|
||||
sourceElement,
|
||||
source: (sourceElement?.textContent || (!sourceElement ? node.textContent : '') || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function resolveKatexRenderTarget(node: HTMLElement, selector: string) {
|
||||
return node.querySelector<HTMLElement>(selector)
|
||||
}
|
||||
|
||||
function hasRenderedKatex(node: HTMLElement, selector: string) {
|
||||
const renderTarget = resolveKatexRenderTarget(node, selector)
|
||||
const scope = renderTarget || node
|
||||
return !!scope.querySelector('.katex, .katex-display')
|
||||
}
|
||||
|
||||
function writeKatexMarkup(
|
||||
node: HTMLElement,
|
||||
renderTarget: HTMLElement | null,
|
||||
markup: string,
|
||||
options: { renderedClass?: string } = {},
|
||||
) {
|
||||
if (renderTarget)
|
||||
renderTarget.innerHTML = markup
|
||||
else
|
||||
node.innerHTML = markup
|
||||
|
||||
node.dataset.markstreamKatex = '1'
|
||||
if (options.renderedClass)
|
||||
node.classList.add(options.renderedClass)
|
||||
}
|
||||
|
||||
function clearKatexMarkup(
|
||||
node: HTMLElement,
|
||||
renderTarget: HTMLElement | null,
|
||||
options: { renderedClass?: string } = {},
|
||||
) {
|
||||
if (renderTarget)
|
||||
renderTarget.innerHTML = ''
|
||||
delete node.dataset.markstreamKatex
|
||||
delete node.dataset.markstreamKatexSource
|
||||
if (options.renderedClass)
|
||||
node.classList.remove(options.renderedClass)
|
||||
}
|
||||
|
||||
async function renderKatex(root: HTMLElement, isActive: () => boolean) {
|
||||
const inlineNodes = Array.from(root.querySelectorAll<HTMLElement>('.markstream-nested-math'))
|
||||
for (const node of inlineNodes) {
|
||||
if (!isActive())
|
||||
return
|
||||
if (node.dataset.markstreamKatexManaged === '1')
|
||||
continue
|
||||
const { source } = readKatexSource(node, '.markstream-nested-math__source')
|
||||
const renderTarget = resolveKatexRenderTarget(node, '.markstream-nested-math__render')
|
||||
if (!source) {
|
||||
clearKatexMarkup(node, renderTarget)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
node.dataset.markstreamKatex === '1'
|
||||
&& node.dataset.markstreamKatexSource === source
|
||||
&& hasRenderedKatex(node, '.markstream-nested-math__render')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
writeKatexMarkup(node, renderTarget, await renderKatexMarkup(source, node.dataset.display === 'block'))
|
||||
node.dataset.markstreamKatexSource = source
|
||||
}
|
||||
catch {
|
||||
clearKatexMarkup(node, renderTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const blockNodes = Array.from(root.querySelectorAll<HTMLElement>('.markstream-nested-math-block'))
|
||||
for (const node of blockNodes) {
|
||||
if (!isActive())
|
||||
return
|
||||
if (node.dataset.markstreamKatexManaged === '1')
|
||||
continue
|
||||
const { source } = readKatexSource(node, '.markstream-nested-math-block__source')
|
||||
const renderTarget = resolveKatexRenderTarget(node, '.markstream-nested-math-block__render')
|
||||
if (!source) {
|
||||
clearKatexMarkup(node, renderTarget, { renderedClass: 'markstream-nested-math-block--rendered' })
|
||||
continue
|
||||
}
|
||||
if (
|
||||
node.dataset.markstreamKatex === '1'
|
||||
&& node.dataset.markstreamKatexSource === source
|
||||
&& hasRenderedKatex(node, '.markstream-nested-math-block__render')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
writeKatexMarkup(node, renderTarget, await renderKatexMarkup(source, true), {
|
||||
renderedClass: 'markstream-nested-math-block--rendered',
|
||||
})
|
||||
node.dataset.markstreamKatexSource = source
|
||||
}
|
||||
catch {
|
||||
clearKatexMarkup(node, renderTarget, { renderedClass: 'markstream-nested-math-block--rendered' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderKatexMarkup(source: string, displayMode: boolean) {
|
||||
const normalizedSource = normalizeKaTeXRenderInput(source)
|
||||
try {
|
||||
return await renderKaTeXWithBackpressure(normalizedSource, 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)
|
||||
throw error
|
||||
}
|
||||
|
||||
const katex = await getKatex()
|
||||
if (!katex)
|
||||
throw new Error('KaTeX renderer is not available.')
|
||||
|
||||
const html = katex.renderToString(normalizedSource, {
|
||||
displayMode,
|
||||
throwOnError: false,
|
||||
})
|
||||
setKaTeXCache(normalizedSource, displayMode, html)
|
||||
return html
|
||||
}
|
||||
|
||||
async function renderMermaid(
|
||||
root: HTMLElement,
|
||||
cleanupFns: Array<() => void>,
|
||||
options: EnhanceRenderedHtmlOptions,
|
||||
isActive: () => boolean,
|
||||
) {
|
||||
const mermaid = await getMermaid({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'loose',
|
||||
suppressErrorRendering: true,
|
||||
})
|
||||
if (!mermaid || !isActive())
|
||||
return
|
||||
|
||||
const codeNodes = Array.from(root.querySelectorAll<HTMLElement>('pre > code.language-mermaid'))
|
||||
for (const codeNode of codeNodes) {
|
||||
if (!isActive())
|
||||
return
|
||||
const pre = codeNode.parentElement as HTMLElement | null
|
||||
const source = (codeNode.textContent || '').trim()
|
||||
if (!pre || !source)
|
||||
continue
|
||||
|
||||
const originalPre = pre.cloneNode(true) as HTMLElement
|
||||
const shell = createEnhancedBlockShell(
|
||||
'mermaid',
|
||||
'Mermaid',
|
||||
source,
|
||||
true,
|
||||
options,
|
||||
{
|
||||
showHeader: options.mermaidProps?.showHeader !== false,
|
||||
},
|
||||
)
|
||||
pre.replaceWith(shell.wrapper)
|
||||
|
||||
try {
|
||||
const theme: MermaidTheme = options.isDark ? 'dark' : 'light'
|
||||
let sourceToRender = source
|
||||
if (options.final === false) {
|
||||
try {
|
||||
await canParseMermaidWithFallback(source, theme, mermaid, options)
|
||||
}
|
||||
catch (error: any) {
|
||||
if (error?.name === 'AbortError')
|
||||
return
|
||||
const prefix = await findMermaidPrefixCandidate(source, theme, options)
|
||||
if (!isActive())
|
||||
return
|
||||
if (!prefix) {
|
||||
shell.wrapper.replaceWith(originalPre)
|
||||
continue
|
||||
}
|
||||
sourceToRender = prefix
|
||||
}
|
||||
}
|
||||
|
||||
const renderId = `markstream-angular-mermaid-${++mermaidRenderId}`
|
||||
const rendered = await withTimeout(
|
||||
() => Promise.resolve(mermaid.render(renderId, applyMermaidThemeTo(sourceToRender, theme))),
|
||||
options.final === false
|
||||
? Number(options.mermaidProps?.renderTimeoutMs ?? 2500)
|
||||
: Number(options.mermaidProps?.fullRenderTimeoutMs ?? 4000),
|
||||
)
|
||||
if (!isActive())
|
||||
return
|
||||
const svg = typeof rendered === 'string' ? rendered : rendered?.svg
|
||||
if (!svg)
|
||||
continue
|
||||
shell.body.innerHTML = svg
|
||||
shell.body.classList.add('markstream-angular-mermaid')
|
||||
shell.wrapper.dataset.markstreamMermaid = '1'
|
||||
rendered?.bindFunctions?.(shell.body)
|
||||
cleanupFns.push(() => {
|
||||
if (shell.wrapper.isConnected)
|
||||
shell.wrapper.replaceWith(originalPre.cloneNode(true))
|
||||
})
|
||||
}
|
||||
catch {
|
||||
shell.wrapper.replaceWith(originalPre)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyMermaidThemeTo(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}`
|
||||
}
|
||||
|
||||
async function canParseMermaidWithFallback(
|
||||
source: string,
|
||||
theme: MermaidTheme,
|
||||
mermaid: any,
|
||||
options: EnhanceRenderedHtmlOptions,
|
||||
) {
|
||||
const workerTimeout = Number(options.mermaidProps?.workerTimeoutMs ?? 1400)
|
||||
const parseTimeout = Number(options.mermaidProps?.parseTimeoutMs ?? 1800)
|
||||
try {
|
||||
const ok = await canParseOffthread(source, theme, workerTimeout)
|
||||
if (ok)
|
||||
return true
|
||||
}
|
||||
catch (error: any) {
|
||||
if (error?.name === 'AbortError')
|
||||
throw error
|
||||
}
|
||||
|
||||
const themedSource = applyMermaidThemeTo(source, theme)
|
||||
if (typeof mermaid?.parse === 'function') {
|
||||
await withTimeout(() => Promise.resolve(mermaid.parse(themedSource)), parseTimeout)
|
||||
return true
|
||||
}
|
||||
|
||||
const renderId = `markstream-angular-mermaid-parse-${++mermaidRenderId}`
|
||||
await withTimeout(() => Promise.resolve(mermaid.render(renderId, themedSource)), parseTimeout)
|
||||
return true
|
||||
}
|
||||
|
||||
async function findMermaidPrefixCandidate(
|
||||
source: string,
|
||||
theme: MermaidTheme,
|
||||
options: EnhanceRenderedHtmlOptions,
|
||||
) {
|
||||
const workerTimeout = Number(options.mermaidProps?.workerTimeoutMs ?? 1400)
|
||||
try {
|
||||
const prefix = await findPrefixOffthread(source, theme, workerTimeout)
|
||||
if (prefix)
|
||||
return prefix
|
||||
}
|
||||
catch (error: any) {
|
||||
if (error?.name === 'AbortError')
|
||||
throw error
|
||||
}
|
||||
return getSafeMermaidPrefixCandidate(source)
|
||||
}
|
||||
|
||||
function getSafeMermaidPrefixCandidate(source: string) {
|
||||
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')
|
||||
}
|
||||
|
||||
function withTimeout<T>(run: () => Promise<T>, timeoutMs: number) {
|
||||
if (!timeoutMs || timeoutMs <= 0)
|
||||
return run()
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
reject(new Error('Operation timed out'))
|
||||
}, timeoutMs)
|
||||
|
||||
run()
|
||||
.then((value) => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function renderInfographic(
|
||||
root: HTMLElement,
|
||||
cleanupFns: Array<() => void>,
|
||||
options: EnhanceRenderedHtmlOptions,
|
||||
isActive: () => boolean,
|
||||
) {
|
||||
const InfographicClass = await getInfographic()
|
||||
if (!InfographicClass || !isActive())
|
||||
return
|
||||
|
||||
const codeNodes = Array.from(root.querySelectorAll<HTMLElement>('pre[data-markstream-code-block="1"] > code.language-infographic'))
|
||||
for (const codeNode of codeNodes) {
|
||||
if (!isActive())
|
||||
return
|
||||
const pre = codeNode.parentElement as HTMLElement | null
|
||||
const source = codeNode.textContent || ''
|
||||
if (!pre || !source.trim())
|
||||
continue
|
||||
|
||||
const originalPre = pre.cloneNode(true) as HTMLElement
|
||||
const shell = createEnhancedBlockShell('infographic', 'Infographic', source, true, options, {
|
||||
showHeader: options.infographicProps?.showHeader !== false,
|
||||
})
|
||||
shell.body.style.minHeight = '320px'
|
||||
shell.body.style.height = '360px'
|
||||
pre.replaceWith(shell.wrapper)
|
||||
|
||||
try {
|
||||
const instance = new InfographicClass({
|
||||
container: shell.body,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
})
|
||||
instance.render(source)
|
||||
|
||||
let frameId: number | null = null
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
const actualHeight = shell.body.scrollHeight
|
||||
if (actualHeight > 0)
|
||||
shell.body.style.height = `${Math.min(Math.max(actualHeight, 260), 720)}px`
|
||||
})
|
||||
}
|
||||
|
||||
cleanupFns.push(() => {
|
||||
if (frameId != null && typeof window !== 'undefined')
|
||||
window.cancelAnimationFrame(frameId)
|
||||
instance.destroy?.()
|
||||
if (shell.wrapper.isConnected)
|
||||
shell.wrapper.replaceWith(originalPre.cloneNode(true))
|
||||
})
|
||||
shell.wrapper.dataset.markstreamInfographic = '1'
|
||||
}
|
||||
catch {
|
||||
shell.wrapper.replaceWith(originalPre)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderD2(
|
||||
root: HTMLElement,
|
||||
cleanupFns: Array<() => void>,
|
||||
options: EnhanceRenderedHtmlOptions,
|
||||
isActive: () => boolean,
|
||||
) {
|
||||
const D2Ctor = await getD2()
|
||||
if (!D2Ctor || !isActive())
|
||||
return
|
||||
|
||||
const codeNodes = Array.from(root.querySelectorAll<HTMLElement>('pre[data-markstream-code-block="1"] > code.language-d2, pre[data-markstream-code-block="1"] > code.language-d2lang'))
|
||||
for (const codeNode of codeNodes) {
|
||||
if (!isActive())
|
||||
return
|
||||
const pre = codeNode.parentElement as HTMLElement | null
|
||||
const source = codeNode.textContent || ''
|
||||
if (!pre || !source.trim())
|
||||
continue
|
||||
|
||||
const originalPre = pre.cloneNode(true) as HTMLElement
|
||||
const shell = createEnhancedBlockShell('d2', 'D2', source, true, options, {
|
||||
showHeader: options.d2Props?.showHeader !== false,
|
||||
})
|
||||
pre.replaceWith(shell.wrapper)
|
||||
|
||||
try {
|
||||
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 resolvedThemeId = options.d2ThemeId ?? baseRenderOptions.themeID
|
||||
const resolvedDarkThemeId = options.d2DarkThemeId ?? baseRenderOptions.darkThemeID
|
||||
const renderOptions: Record<string, any> = { ...baseRenderOptions }
|
||||
renderOptions.themeID = options.isDark && resolvedDarkThemeId != null
|
||||
? resolvedDarkThemeId
|
||||
: resolvedThemeId
|
||||
renderOptions.darkThemeID = null
|
||||
renderOptions.darkThemeOverrides = null
|
||||
if (options.isDark) {
|
||||
const baseOverrides = baseRenderOptions.themeOverrides && typeof baseRenderOptions.themeOverrides === 'object'
|
||||
? baseRenderOptions.themeOverrides
|
||||
: null
|
||||
renderOptions.themeOverrides = {
|
||||
...DARK_THEME_OVERRIDES,
|
||||
...(baseOverrides || {}),
|
||||
}
|
||||
}
|
||||
|
||||
const renderResult = await instance.render(diagram, renderOptions)
|
||||
if (!isActive())
|
||||
return
|
||||
const safeSvg = toSafeSvgMarkup(extractRenderedSvg(renderResult))
|
||||
if (!safeSvg)
|
||||
throw new Error('D2 render returned empty output.')
|
||||
|
||||
shell.body.innerHTML = safeSvg
|
||||
shell.wrapper.dataset.markstreamD2 = '1'
|
||||
cleanupFns.push(() => {
|
||||
if (shell.wrapper.isConnected)
|
||||
shell.wrapper.replaceWith(originalPre.cloneNode(true))
|
||||
})
|
||||
}
|
||||
catch {
|
||||
shell.wrapper.replaceWith(originalPre)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderMonaco(
|
||||
root: HTMLElement,
|
||||
cleanupFns: Array<() => void>,
|
||||
options: EnhanceRenderedHtmlOptions,
|
||||
isActive: () => boolean,
|
||||
) {
|
||||
const monacoModule = await getUseMonaco()
|
||||
if (!monacoModule || typeof monacoModule.useMonaco !== 'function' || !isActive())
|
||||
return
|
||||
|
||||
const preNodes = Array.from(root.querySelectorAll<HTMLElement>('pre[data-markstream-code-block="1"]'))
|
||||
for (const pre of preNodes) {
|
||||
if (!isActive())
|
||||
return
|
||||
const codeNode = pre.querySelector<HTMLElement>('code')
|
||||
if (!codeNode)
|
||||
continue
|
||||
|
||||
const rawLanguage = resolveCodeLanguage(pre, codeNode)
|
||||
const normalizedLanguage = rawLanguage.trim().toLowerCase()
|
||||
if (normalizedLanguage === 'mermaid' || normalizedLanguage === 'infographic' || normalizedLanguage === 'd2' || normalizedLanguage === 'd2lang')
|
||||
continue
|
||||
|
||||
const source = codeNode.textContent || ''
|
||||
const diff = pre.dataset.markstreamDiff === '1'
|
||||
const updatedCode = decodeDataPayload(pre.dataset.markstreamUpdated)
|
||||
const monacoLanguage = resolveMonacoLanguage(rawLanguage)
|
||||
const renderLanguage = diff ? 'plaintext' : monacoLanguage
|
||||
const shell = createEnhancedBlockShell(
|
||||
'code',
|
||||
diff ? `Diff / ${monacoLanguage}` : `Code / ${monacoLanguage}`,
|
||||
source,
|
||||
false,
|
||||
options,
|
||||
{
|
||||
showHeader: options.codeBlockProps?.showHeader !== false,
|
||||
},
|
||||
)
|
||||
shell.body.classList.add('markstream-angular-enhanced-block__body--code')
|
||||
shell.body.style.minHeight = `${estimateCodeBlockHeight(diff ? updatedCode || source : source, diff)}px`
|
||||
const originalPre = pre.cloneNode(true) as HTMLElement
|
||||
pre.replaceWith(shell.wrapper)
|
||||
|
||||
const helpers = monacoModule.useMonaco({
|
||||
themes: ['vitesse-dark', 'vitesse-light'],
|
||||
languages: Array.from(new Set([monacoLanguage, 'plaintext'])),
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: 'on',
|
||||
wordWrap: 'off',
|
||||
revealDebounceMs: 75,
|
||||
MAX_HEIGHT: 500,
|
||||
fontSize: 13,
|
||||
...(options.monacoOptions || {}),
|
||||
})
|
||||
|
||||
try {
|
||||
// Vue/React wire Monaco diff editors through dedicated components.
|
||||
// Angular currently enhances static HTML after the fact, and the diff
|
||||
// editor path in stream-monaco can leave an empty shell or raise
|
||||
// "no diff result available" during worker setup. Prefer a stable
|
||||
// single-editor fallback that still preserves the diff source text.
|
||||
await helpers.createEditor(
|
||||
shell.body,
|
||||
source,
|
||||
renderLanguage,
|
||||
)
|
||||
if (!isActive())
|
||||
return
|
||||
|
||||
await helpers.setTheme?.(options.isDark ? 'vitesse-dark' : 'vitesse-light')
|
||||
shell.wrapper.dataset.markstreamMonaco = '1'
|
||||
if (diff)
|
||||
shell.wrapper.dataset.markstreamMonacoDiff = '1'
|
||||
cleanupFns.push(() => {
|
||||
try {
|
||||
helpers.cleanupEditor?.()
|
||||
}
|
||||
finally {
|
||||
if (shell.wrapper.isConnected)
|
||||
shell.wrapper.replaceWith(originalPre.cloneNode(true))
|
||||
}
|
||||
})
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
helpers.cleanupEditor?.()
|
||||
}
|
||||
catch {
|
||||
// Ignore cleanup failures during fallback.
|
||||
}
|
||||
shell.wrapper.replaceWith(originalPre)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function resolveCodeLanguage(pre: HTMLElement, codeNode: HTMLElement) {
|
||||
const explicit = pre.dataset.markstreamLanguage?.trim()
|
||||
if (explicit)
|
||||
return explicit
|
||||
|
||||
const languageClass = Array.from(codeNode.classList).find(className => className.startsWith('language-'))
|
||||
return languageClass ? languageClass.slice('language-'.length) : 'plaintext'
|
||||
}
|
||||
|
||||
function resolveMonacoLanguage(language: string) {
|
||||
const normalized = language.trim().toLowerCase()
|
||||
return MONACO_LANGUAGE_ALIASES[normalized] || normalized || 'plaintext'
|
||||
}
|
||||
|
||||
function estimateCodeBlockHeight(source: string, diff: boolean) {
|
||||
const lineCount = Math.max(1, source.split('\n').length)
|
||||
const perLine = diff ? 20 : 18
|
||||
const base = diff ? 180 : 96
|
||||
return Math.min(520, base + lineCount * perLine)
|
||||
}
|
||||
|
||||
function decodeDataPayload(value: string | null | undefined) {
|
||||
if (!value)
|
||||
return ''
|
||||
|
||||
const globalBuffer = (globalThis as any)?.require?.('buffer')?.Buffer
|
||||
if (globalBuffer?.from)
|
||||
return globalBuffer.from(value, 'base64').toString('utf8')
|
||||
|
||||
if (typeof globalThis.atob === 'function' && typeof TextDecoder !== 'undefined') {
|
||||
const binary = globalThis.atob(value)
|
||||
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function createEnhancedBlockShell(
|
||||
kind: 'code' | 'd2' | 'infographic' | 'mermaid',
|
||||
label: string,
|
||||
source: string,
|
||||
showSourceDetails: boolean,
|
||||
options: EnhanceRenderedHtmlOptions,
|
||||
shellOptions: {
|
||||
showHeader?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = `markstream-angular-enhanced-block markstream-angular-enhanced-block--${kind}`
|
||||
|
||||
if (shellOptions.showHeader !== false) {
|
||||
const header = document.createElement('div')
|
||||
header.className = 'markstream-angular-enhanced-block__header'
|
||||
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'markstream-angular-enhanced-block__badge'
|
||||
badge.textContent = label
|
||||
|
||||
header.appendChild(badge)
|
||||
header.appendChild(createHeaderActions(source, options))
|
||||
wrapper.appendChild(header)
|
||||
}
|
||||
|
||||
const body = document.createElement('div')
|
||||
body.className = 'markstream-angular-enhanced-block__body'
|
||||
wrapper.appendChild(body)
|
||||
|
||||
if (showSourceDetails) {
|
||||
const details = document.createElement('details')
|
||||
details.className = 'markstream-angular-enhanced-block__details'
|
||||
|
||||
const summary = document.createElement('summary')
|
||||
summary.textContent = 'Source'
|
||||
details.appendChild(summary)
|
||||
|
||||
const sourcePre = document.createElement('pre')
|
||||
sourcePre.className = 'markstream-angular-enhanced-block__source'
|
||||
const code = document.createElement('code')
|
||||
code.textContent = source
|
||||
sourcePre.appendChild(code)
|
||||
details.appendChild(sourcePre)
|
||||
|
||||
wrapper.appendChild(details)
|
||||
}
|
||||
|
||||
return { wrapper, body }
|
||||
}
|
||||
|
||||
function createHeaderActions(source: string, options: EnhanceRenderedHtmlOptions) {
|
||||
const actions = document.createElement('div')
|
||||
actions.className = 'markstream-angular-enhanced-block__actions'
|
||||
|
||||
const copyButton = document.createElement('button')
|
||||
copyButton.type = 'button'
|
||||
copyButton.className = 'markstream-angular-enhanced-block__action'
|
||||
copyButton.textContent = 'Copy'
|
||||
if (options.showTooltips !== false)
|
||||
copyButton.title = 'Copy source'
|
||||
|
||||
copyButton.addEventListener('click', () => {
|
||||
void copyText(source)
|
||||
options.onCopy?.(source)
|
||||
})
|
||||
|
||||
actions.appendChild(copyButton)
|
||||
return actions
|
||||
}
|
||||
|
||||
async function copyText(source: string) {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(source)
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Fall through to the textarea fallback below.
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined')
|
||||
return
|
||||
|
||||
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')
|
||||
}
|
||||
catch {
|
||||
// Ignore copy failures; the output callback still fires.
|
||||
}
|
||||
finally {
|
||||
textarea.remove()
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import type { BaseNode } from 'stream-markdown-parser'
|
||||
import { normalizeCustomHtmlTags, normalizeCustomHtmlTagName as normalizeTagName } from 'stream-markdown-parser'
|
||||
|
||||
interface CustomTagSegment {
|
||||
tag: string
|
||||
start: number
|
||||
end: number
|
||||
innerContent: string
|
||||
raw: string
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
interface CustomTagOpen {
|
||||
tag: string
|
||||
start: number
|
||||
openEnd: number
|
||||
}
|
||||
|
||||
const TAG_TOKEN_RE = /<\/?([A-Z][\w:-]*)(?:\s[^<>]*)?>/gi
|
||||
|
||||
export function hydrateCustomTagContent<T extends BaseNode>(
|
||||
nodes: readonly T[] | null | undefined,
|
||||
source: string,
|
||||
customHtmlTags?: readonly string[],
|
||||
): T[] {
|
||||
const normalizedTags = normalizeCustomHtmlTags(customHtmlTags)
|
||||
const tagSet = new Set(normalizedTags)
|
||||
if (!source || tagSet.size === 0 || !Array.isArray(nodes) || nodes.length === 0)
|
||||
return Array.isArray(nodes) ? nodes.slice() : []
|
||||
|
||||
const segments = collectCustomTagSegments(source, tagSet)
|
||||
if (segments.length === 0)
|
||||
return nodes.slice()
|
||||
|
||||
let segmentIndex = 0
|
||||
const cloned = nodes.map(node => cloneNodeTree(node))
|
||||
|
||||
const visitNode = (node: any) => {
|
||||
if (!node || typeof node !== 'object')
|
||||
return
|
||||
|
||||
const tag = resolveCustomTagName(node, tagSet)
|
||||
if (tag) {
|
||||
const segment = consumeNextSegment(segments, tag, () => segmentIndex, nextIndex => (segmentIndex = nextIndex))
|
||||
if (segment) {
|
||||
node.tag = tag
|
||||
node.type = tag
|
||||
node.content = segment.innerContent
|
||||
node.raw = segment.raw
|
||||
if (typeof node.loading !== 'boolean')
|
||||
node.loading = segment.loading
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(node)) {
|
||||
if (!Array.isArray(value))
|
||||
continue
|
||||
for (const child of value)
|
||||
visitNode(child)
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of cloned)
|
||||
visitNode(node)
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
function collectCustomTagSegments(source: string, tagSet: Set<string>) {
|
||||
const stack: CustomTagOpen[] = []
|
||||
const segments: CustomTagSegment[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
TAG_TOKEN_RE.lastIndex = 0
|
||||
while ((match = TAG_TOKEN_RE.exec(source)) !== null) {
|
||||
const raw = match[0]
|
||||
const tag = normalizeTagName(match[1])
|
||||
if (!tag || !tagSet.has(tag))
|
||||
continue
|
||||
|
||||
const isClosing = raw.startsWith('</')
|
||||
if (!isClosing) {
|
||||
stack.push({
|
||||
tag,
|
||||
start: match.index,
|
||||
openEnd: TAG_TOKEN_RE.lastIndex,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const openIndex = findLastOpenIndex(stack, tag)
|
||||
if (openIndex < 0)
|
||||
continue
|
||||
|
||||
const [open] = stack.splice(openIndex, 1)
|
||||
const closeStart = match.index
|
||||
const closeEnd = TAG_TOKEN_RE.lastIndex
|
||||
segments.push({
|
||||
tag,
|
||||
start: open.start,
|
||||
end: closeEnd,
|
||||
innerContent: source.slice(open.openEnd, closeStart),
|
||||
raw: source.slice(open.start, closeEnd),
|
||||
loading: false,
|
||||
})
|
||||
}
|
||||
|
||||
for (const open of stack) {
|
||||
segments.push({
|
||||
tag: open.tag,
|
||||
start: open.start,
|
||||
end: source.length,
|
||||
innerContent: source.slice(open.openEnd),
|
||||
raw: source.slice(open.start),
|
||||
loading: true,
|
||||
})
|
||||
}
|
||||
|
||||
return segments.sort((left, right) => {
|
||||
if (left.start !== right.start)
|
||||
return left.start - right.start
|
||||
return right.end - left.end
|
||||
})
|
||||
}
|
||||
|
||||
function findLastOpenIndex(stack: CustomTagOpen[], tag: string) {
|
||||
for (let index = stack.length - 1; index >= 0; index -= 1) {
|
||||
if (stack[index]?.tag === tag)
|
||||
return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function consumeNextSegment(
|
||||
segments: CustomTagSegment[],
|
||||
tag: string,
|
||||
getIndex: () => number,
|
||||
setIndex: (value: number) => void,
|
||||
) {
|
||||
let index = getIndex()
|
||||
while (index < segments.length) {
|
||||
const segment = segments[index]
|
||||
index += 1
|
||||
if (segment.tag !== tag)
|
||||
continue
|
||||
setIndex(index)
|
||||
return segment
|
||||
}
|
||||
setIndex(index)
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveCustomTagName(node: Record<string, any>, tagSet: Set<string>) {
|
||||
const byTag = normalizeTagName(node.tag)
|
||||
if (byTag && tagSet.has(byTag))
|
||||
return byTag
|
||||
|
||||
const byType = normalizeTagName(node.type)
|
||||
return byType && tagSet.has(byType) ? byType : ''
|
||||
}
|
||||
|
||||
function cloneNodeTree<T extends BaseNode>(node: T): T {
|
||||
if (!node || typeof node !== 'object')
|
||||
return node
|
||||
|
||||
const cloned = Array.isArray(node)
|
||||
? node.map(item => cloneNodeTree(item)) as unknown as T
|
||||
: { ...(node as any) }
|
||||
|
||||
if (!Array.isArray(cloned)) {
|
||||
for (const [key, value] of Object.entries(cloned as Record<string, unknown>)) {
|
||||
if (Array.isArray(value))
|
||||
(cloned as any)[key] = value.map(item => cloneNodeTree(item as BaseNode))
|
||||
}
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
function humanizeKey(key: string) {
|
||||
const suffix = key.split('.').pop() || key
|
||||
return suffix
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\b\w/g, char => char.toUpperCase())
|
||||
.trim()
|
||||
}
|
||||
|
||||
const defaultMap: Record<string, string> = {
|
||||
'common.copy': 'Copy',
|
||||
'common.copied': 'Copied',
|
||||
'common.decrease': 'Decrease',
|
||||
'common.reset': 'Reset',
|
||||
'common.increase': 'Increase',
|
||||
'common.expand': 'Expand',
|
||||
'common.collapse': 'Collapse',
|
||||
'common.preview': 'Preview',
|
||||
'common.source': 'Source',
|
||||
'common.export': 'Export',
|
||||
'common.open': 'Open',
|
||||
'common.close': 'Close',
|
||||
'common.zoomIn': 'Zoom in',
|
||||
'common.zoomOut': 'Zoom out',
|
||||
'common.resetZoom': 'Reset zoom',
|
||||
'artifacts.htmlPreviewTitle': 'HTML Preview',
|
||||
'artifacts.svgPreviewTitle': 'SVG Preview',
|
||||
}
|
||||
|
||||
export function setDefaultI18nMap(map: Record<string, string>) {
|
||||
Object.assign(defaultMap, map)
|
||||
}
|
||||
|
||||
export function useSafeI18n() {
|
||||
return {
|
||||
t(key: string) {
|
||||
return defaultMap[key] ?? humanizeKey(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
import './index.css'
|
||||
import './workers/katexRenderer.worker?worker'
|
||||
import './workers/mermaidParser.worker?worker'
|
||||
|
||||
export { AdmonitionNodeComponent as AdmonitionNode } from './components/AdmonitionNode/AdmonitionNode.component'
|
||||
export { BlockquoteNodeComponent as BlockquoteNode } from './components/BlockquoteNode/BlockquoteNode.component'
|
||||
export { CheckboxNodeComponent as CheckboxNode } from './components/CheckboxNode/CheckboxNode.component'
|
||||
export { CodeBlockNodeComponent as CodeBlockNode } from './components/CodeBlockNode/CodeBlockNode.component'
|
||||
export { CodeBlockNodeComponent as AngularCodeBlockNode } from './components/CodeBlockNode/CodeBlockNode.component'
|
||||
export { CodeBlockNodeComponent as MarkdownCodeBlockNode } from './components/CodeBlockNode/CodeBlockNode.component'
|
||||
export { HtmlPreviewFrameComponent as HtmlPreviewFrame } from './components/CodeBlockNode/HtmlPreviewFrame.component'
|
||||
export { D2BlockNodeComponent as D2BlockNode } from './components/D2BlockNode/D2BlockNode.component'
|
||||
export { DefinitionListNodeComponent as DefinitionListNode } from './components/DefinitionListNode/DefinitionListNode.component'
|
||||
export { EmojiNodeComponent as EmojiNode } from './components/EmojiNode/EmojiNode.component'
|
||||
export { EmphasisNodeComponent as EmphasisNode } from './components/EmphasisNode/EmphasisNode.component'
|
||||
export { FallbackComponent } from './components/FallbackComponent/FallbackComponent.component'
|
||||
export { FootnoteAnchorNodeComponent as FootnoteAnchorNode } from './components/FootnoteAnchorNode/FootnoteAnchorNode.component'
|
||||
export { FootnoteNodeComponent as FootnoteNode } from './components/FootnoteNode/FootnoteNode.component'
|
||||
export { FootnoteReferenceNodeComponent as FootnoteReferenceNode } from './components/FootnoteReferenceNode/FootnoteReferenceNode.component'
|
||||
export { HardBreakNodeComponent as HardBreakNode } from './components/HardBreakNode/HardBreakNode.component'
|
||||
export { HeadingNodeComponent as HeadingNode } from './components/HeadingNode/HeadingNode.component'
|
||||
export { HighlightNodeComponent as HighlightNode } from './components/HighlightNode/HighlightNode.component'
|
||||
export { HtmlBlockNodeComponent as HtmlBlockNode } from './components/HtmlBlockNode/HtmlBlockNode.component'
|
||||
export { HtmlInlineNodeComponent as HtmlInlineNode } from './components/HtmlInlineNode/HtmlInlineNode.component'
|
||||
export { ImageNodeComponent as ImageNode } from './components/ImageNode/ImageNode.component'
|
||||
export { InfographicBlockNodeComponent as InfographicBlockNode } from './components/InfographicBlockNode/InfographicBlockNode.component'
|
||||
export { InlineCodeNodeComponent as InlineCodeNode } from './components/InlineCodeNode/InlineCodeNode.component'
|
||||
export { InsertNodeComponent as InsertNode } from './components/InsertNode/InsertNode.component'
|
||||
export { LinkNodeComponent as LinkNode } from './components/LinkNode/LinkNode.component'
|
||||
export { ListItemNodeComponent as ListItemNode } from './components/ListItemNode/ListItemNode.component'
|
||||
export { ListNodeComponent as ListNode } from './components/ListNode/ListNode.component'
|
||||
export { MathBlockNodeComponent as MathBlockNode } from './components/MathBlockNode/MathBlockNode.component'
|
||||
export { MathInlineNodeComponent as MathInlineNode } from './components/MathInlineNode/MathInlineNode.component'
|
||||
export { MermaidBlockNodeComponent as MermaidBlockNode } from './components/MermaidBlockNode/MermaidBlockNode.component'
|
||||
export { NestedRendererComponent as NestedRenderer } from './components/NestedRenderer/NestedRenderer.component'
|
||||
export { NodeRendererComponent as NodeRenderer } from './components/NodeRenderer/NodeRenderer.component'
|
||||
export { ParagraphNodeComponent as ParagraphNode } from './components/ParagraphNode/ParagraphNode.component'
|
||||
export { PreCodeNodeComponent as PreCodeNode } from './components/PreCodeNode/PreCodeNode.component'
|
||||
export { ReferenceNodeComponent as ReferenceNode } from './components/ReferenceNode/ReferenceNode.component'
|
||||
export type {
|
||||
AngularRenderableNode,
|
||||
AngularRenderContext,
|
||||
NodeRendererEvents,
|
||||
NodeRendererProps,
|
||||
} from './components/shared/node-helpers'
|
||||
export {
|
||||
buildRenderContext,
|
||||
resolveParsedNodes,
|
||||
} from './components/shared/node-helpers'
|
||||
export { SafeAttrsDirective } from './components/shared/safe-attrs.directive'
|
||||
export { StrikethroughNodeComponent as StrikethroughNode } from './components/StrikethroughNode/StrikethroughNode.component'
|
||||
export { StrongNodeComponent as StrongNode } from './components/StrongNode/StrongNode.component'
|
||||
export { SubscriptNodeComponent as SubscriptNode } from './components/SubscriptNode/SubscriptNode.component'
|
||||
export { SuperscriptNodeComponent as SuperscriptNode } from './components/SuperscriptNode/SuperscriptNode.component'
|
||||
export { TableNodeComponent as TableNode } from './components/TableNode/TableNode.component'
|
||||
export { TextNodeComponent as TextNode } from './components/TextNode/TextNode.component'
|
||||
export { ThematicBreakNodeComponent as ThematicBreakNode } from './components/ThematicBreakNode/ThematicBreakNode.component'
|
||||
export { VmrContainerNodeComponent as VmrContainerNode } from './components/VmrContainerNode/VmrContainerNode.component'
|
||||
export {
|
||||
clearGlobalCustomComponents,
|
||||
getCustomComponentsRevision,
|
||||
getCustomNodeComponents,
|
||||
removeCustomComponents,
|
||||
setCustomComponents,
|
||||
subscribeCustomComponents,
|
||||
} from './customComponents'
|
||||
export type { CustomComponentMap } from './customComponents'
|
||||
export {
|
||||
disposeRenderedHtmlEnhancements,
|
||||
enhanceRenderedHtml,
|
||||
} from './enhanceRenderedHtml'
|
||||
export type { EnhanceRenderedHtmlOptions, RenderedHtmlEnhancementHandle } from './enhanceRenderedHtml'
|
||||
export { setDefaultI18nMap, useSafeI18n } from './i18n/useSafeI18n'
|
||||
export { MarkstreamAngularComponent } from './markstream-angular.component'
|
||||
export { MarkstreamAngularComponent as MarkdownRenderComponent } from './markstream-angular.component'
|
||||
export { MarkstreamAngularComponent as default } from './markstream-angular.component'
|
||||
export type { MarkstreamAngularComponentProps } from './markstream-angular.component'
|
||||
export type { D2Loader } from './optional/d2'
|
||||
export {
|
||||
disableD2,
|
||||
enableD2,
|
||||
isD2Enabled,
|
||||
setD2Loader,
|
||||
} from './optional/d2'
|
||||
export type { KatexLoader } from './optional/katex'
|
||||
export {
|
||||
disableKatex,
|
||||
enableKatex,
|
||||
getKatex,
|
||||
isKatexEnabled,
|
||||
setKatexLoader,
|
||||
} from './optional/katex'
|
||||
export type { MermaidLoader } from './optional/mermaid'
|
||||
export {
|
||||
disableMermaid,
|
||||
enableMermaid,
|
||||
getMermaid,
|
||||
isMermaidEnabled,
|
||||
setMermaidLoader,
|
||||
} from './optional/mermaid'
|
||||
export {
|
||||
parseNestedMarkdownToNodes,
|
||||
} from './parseNestedMarkdownToNodes'
|
||||
export type {
|
||||
NestedMarkdownNodesInput,
|
||||
NestedMarkdownNodesOptions,
|
||||
} from './parseNestedMarkdownToNodes'
|
||||
export {
|
||||
renderMarkdownNodesToHtml,
|
||||
renderMarkdownNodeToHtml,
|
||||
renderMarkdownToHtml,
|
||||
renderNestedMarkdownToHtml,
|
||||
} from './renderMarkdownHtml'
|
||||
export type {
|
||||
MarkstreamAngularRenderOptions,
|
||||
NestedMarkdownHtmlInput,
|
||||
NestedMarkdownHtmlOptions,
|
||||
RenderableMarkdownNode,
|
||||
} from './renderMarkdownHtml'
|
||||
export { sanitizeHtmlContent } from './sanitizeHtmlContent'
|
||||
export type {
|
||||
CodeBlockDiffAppearance,
|
||||
CodeBlockDiffHideUnchangedRegions,
|
||||
CodeBlockDiffHideUnchangedRegionsOptions,
|
||||
CodeBlockDiffHunkActionContext,
|
||||
CodeBlockDiffHunkActionKind,
|
||||
CodeBlockDiffHunkSide,
|
||||
CodeBlockDiffLineStyle,
|
||||
CodeBlockDiffUnchangedRegionStyle,
|
||||
CodeBlockMonacoLanguage,
|
||||
CodeBlockMonacoOptions,
|
||||
CodeBlockMonacoTheme,
|
||||
CodeBlockMonacoThemeObject,
|
||||
} from './types/monaco'
|
||||
export {
|
||||
getLanguageIcon,
|
||||
languageMap,
|
||||
normalizeLanguageIdentifier,
|
||||
resolveMonacoLanguageId,
|
||||
setLanguageIconResolver,
|
||||
} from './utils/languageIcon'
|
||||
export type { LanguageIconResolver } from './utils/languageIcon'
|
||||
export * from './workers/katexCdnWorker'
|
||||
export * from './workers/katexWorkerClient'
|
||||
export * from './workers/mermaidCdnWorker'
|
||||
|
||||
export * from './workers/mermaidWorkerClient'
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export { NodeRendererComponent as MarkstreamAngularComponent } from './components/NodeRenderer/NodeRenderer.component'
|
||||
export type { NodeRendererProps as MarkstreamAngularComponentProps } from './components/shared/node-helpers'
|
||||
@@ -0,0 +1,81 @@
|
||||
export type D2Loader = () => Promise<any> | any
|
||||
|
||||
const defaultD2Loader: D2Loader = () => import('@terrastruct/d2')
|
||||
|
||||
let cachedD2: any = null
|
||||
let d2Loader: D2Loader | null = defaultD2Loader
|
||||
let pendingImport: Promise<any | null> | null = null
|
||||
|
||||
function resetCachedD2() {
|
||||
cachedD2 = null
|
||||
pendingImport = null
|
||||
}
|
||||
|
||||
function normalizeD2Module(mod: any) {
|
||||
if (!mod)
|
||||
return mod
|
||||
if (mod.D2 && typeof mod.D2 === 'function')
|
||||
return mod.D2
|
||||
if (mod.default && mod.default.D2 && typeof mod.default.D2 === 'function')
|
||||
return mod.default.D2
|
||||
const candidate = mod.default ?? mod
|
||||
if (typeof candidate === 'function')
|
||||
return candidate
|
||||
if (candidate?.D2 && typeof candidate.D2 === 'function')
|
||||
return candidate.D2
|
||||
return candidate
|
||||
}
|
||||
|
||||
export function setD2Loader(loader: D2Loader | null) {
|
||||
d2Loader = loader
|
||||
resetCachedD2()
|
||||
}
|
||||
|
||||
export function enableD2(loader?: D2Loader) {
|
||||
setD2Loader(loader ?? defaultD2Loader)
|
||||
}
|
||||
|
||||
export function disableD2() {
|
||||
setD2Loader(null)
|
||||
}
|
||||
|
||||
export function isD2Enabled() {
|
||||
return typeof d2Loader === 'function'
|
||||
}
|
||||
|
||||
export async function getD2() {
|
||||
if (cachedD2)
|
||||
return cachedD2
|
||||
if (pendingImport)
|
||||
return await pendingImport
|
||||
|
||||
const loader = d2Loader
|
||||
if (!loader)
|
||||
return null
|
||||
|
||||
pendingImport = (async () => {
|
||||
let mod: any
|
||||
try {
|
||||
mod = await loader()
|
||||
}
|
||||
catch (error) {
|
||||
if (loader === defaultD2Loader) {
|
||||
throw new Error('Optional dependency "@terrastruct/d2" is not installed. Please install it to enable D2 diagrams.')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!mod)
|
||||
return null
|
||||
|
||||
cachedD2 = normalizeD2Module(mod)
|
||||
return cachedD2
|
||||
})()
|
||||
|
||||
try {
|
||||
return await pendingImport
|
||||
}
|
||||
finally {
|
||||
pendingImport = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
let infographicPromise: Promise<any> | null = null
|
||||
let infographicInstance: any = null
|
||||
|
||||
export async function getInfographic() {
|
||||
if (infographicInstance)
|
||||
return infographicInstance
|
||||
if (infographicPromise)
|
||||
return await infographicPromise
|
||||
|
||||
infographicPromise = import('@antv/infographic')
|
||||
.then((mod) => {
|
||||
const defaultExport = (mod && (mod as any).default) ? (mod as any).default : mod
|
||||
|
||||
let resolved = defaultExport
|
||||
|
||||
if (typeof defaultExport === 'function' && defaultExport.prototype && defaultExport.prototype.render) {
|
||||
resolved = defaultExport
|
||||
}
|
||||
else if ((mod as any)?.Infographic) {
|
||||
resolved = (mod as any).Infographic
|
||||
}
|
||||
else if (defaultExport && defaultExport.Infographic) {
|
||||
resolved = defaultExport.Infographic
|
||||
}
|
||||
|
||||
infographicInstance = resolved
|
||||
return resolved
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[markstream-angular] Failed to load @antv/infographic', error)
|
||||
return null
|
||||
})
|
||||
|
||||
return await infographicPromise
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export type KatexLoader = () => Promise<any> | any
|
||||
|
||||
let katex: any = null
|
||||
let importAttempted = false
|
||||
let pendingImport: Promise<any | null> | null = null
|
||||
let katexLoader: KatexLoader | null = defaultKatexLoader
|
||||
|
||||
function normalizeKatexModule(mod: any) {
|
||||
const resolved = mod?.default ?? mod
|
||||
if (resolved && typeof resolved.renderToString === 'function')
|
||||
return resolved
|
||||
return null
|
||||
}
|
||||
|
||||
function getGlobalKatex() {
|
||||
try {
|
||||
const globalStore = globalThis as any
|
||||
return normalizeKatexModule(globalStore?.katex)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function defaultKatexLoader() {
|
||||
return (async () => {
|
||||
const globalKatex = getGlobalKatex()
|
||||
if (globalKatex)
|
||||
return globalKatex
|
||||
|
||||
const mod = await import('katex')
|
||||
try {
|
||||
await import('katex/contrib/mhchem')
|
||||
}
|
||||
catch {
|
||||
// Ignore optional mhchem support failures.
|
||||
}
|
||||
return normalizeKatexModule(mod)
|
||||
})()
|
||||
}
|
||||
|
||||
function resetCache() {
|
||||
katex = null
|
||||
importAttempted = false
|
||||
pendingImport = null
|
||||
}
|
||||
|
||||
export function setKatexLoader(loader: KatexLoader | null) {
|
||||
katexLoader = loader
|
||||
resetCache()
|
||||
}
|
||||
|
||||
export function enableKatex(loader?: KatexLoader) {
|
||||
setKatexLoader(loader ?? defaultKatexLoader)
|
||||
}
|
||||
|
||||
export function disableKatex() {
|
||||
setKatexLoader(null)
|
||||
}
|
||||
|
||||
export function isKatexEnabled() {
|
||||
return typeof katexLoader === 'function'
|
||||
}
|
||||
|
||||
export async function getKatex() {
|
||||
const globalKatex = getGlobalKatex()
|
||||
if (globalKatex) {
|
||||
katex = globalKatex
|
||||
return katex
|
||||
}
|
||||
|
||||
if (katex)
|
||||
return katex
|
||||
if (pendingImport)
|
||||
return await pendingImport
|
||||
if (importAttempted)
|
||||
return null
|
||||
|
||||
const loader = katexLoader
|
||||
if (!loader) {
|
||||
importAttempted = true
|
||||
return null
|
||||
}
|
||||
|
||||
pendingImport = (async () => {
|
||||
try {
|
||||
const result = await loader()
|
||||
const normalized = normalizeKatexModule(result) ?? result
|
||||
if (normalized) {
|
||||
katex = normalized
|
||||
return katex
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Swallow loader errors so callers can gracefully fall back.
|
||||
}
|
||||
|
||||
importAttempted = true
|
||||
return null
|
||||
})()
|
||||
|
||||
try {
|
||||
return await pendingImport
|
||||
}
|
||||
finally {
|
||||
pendingImport = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
export type MermaidLoader = () => Promise<any> | any
|
||||
|
||||
const defaultMermaidLoader: MermaidLoader = () => import('mermaid')
|
||||
const GLOBAL_MERMAID_KEY = '__MARKSTREAM_ANGULAR_MERMAID__'
|
||||
|
||||
let cachedMermaid: any = null
|
||||
let importAttempted = false
|
||||
let lastInitKey: string | null = null
|
||||
let pendingImport: Promise<any | null> | null = null
|
||||
let mermaidLoader: MermaidLoader | null = defaultMermaidLoader
|
||||
|
||||
function computeInitKey(config: Record<string, any>) {
|
||||
const securityLevel = String(config?.securityLevel ?? 'loose')
|
||||
const htmlLabels = config?.flowchart?.htmlLabels
|
||||
return `${securityLevel}|htmlLabels:${htmlLabels === false ? '0' : '1'}`
|
||||
}
|
||||
|
||||
function normalizeMermaidModule(mod: any) {
|
||||
if (!mod)
|
||||
return mod
|
||||
|
||||
const candidate = mod?.default ?? mod
|
||||
if (candidate && (typeof candidate.render === 'function' || typeof candidate.parse === 'function' || typeof candidate.initialize === 'function'))
|
||||
return candidate
|
||||
|
||||
if (candidate?.mermaidAPI && (typeof candidate.mermaidAPI.render === 'function' || typeof candidate.mermaidAPI.parse === 'function')) {
|
||||
const api = candidate.mermaidAPI
|
||||
return {
|
||||
...candidate,
|
||||
render: api.render.bind(api),
|
||||
parse: api.parse ? api.parse.bind(api) : undefined,
|
||||
initialize: (opts: any) => {
|
||||
if (typeof candidate.initialize === 'function')
|
||||
return candidate.initialize(opts)
|
||||
return api.initialize ? api.initialize(opts) : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (mod?.mermaid && typeof mod.mermaid.render === 'function')
|
||||
return mod.mermaid
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
function patchInitialize(target: any) {
|
||||
if (!target)
|
||||
return
|
||||
|
||||
try {
|
||||
const originalInitialize = target?.initialize
|
||||
target.initialize = (opts: any) => {
|
||||
const merged = { suppressErrorRendering: true, ...(opts || {}) }
|
||||
if (typeof originalInitialize === 'function')
|
||||
return originalInitialize.call(target, merged)
|
||||
if (target?.mermaidAPI && typeof target.mermaidAPI.initialize === 'function')
|
||||
return target.mermaidAPI.initialize(merged)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ignore patching failures and keep the original instance.
|
||||
}
|
||||
}
|
||||
|
||||
function ensureInitialized(instance: any, config?: Record<string, any>) {
|
||||
if (!instance || !config)
|
||||
return
|
||||
|
||||
const key = computeInitKey(config)
|
||||
if (lastInitKey === key)
|
||||
return
|
||||
|
||||
try {
|
||||
if (typeof instance.initialize === 'function')
|
||||
instance.initialize(config)
|
||||
else if (instance.mermaidAPI?.initialize)
|
||||
instance.mermaidAPI.initialize(config)
|
||||
lastInitKey = key
|
||||
}
|
||||
catch {
|
||||
// Mermaid may already be initialized; keep going.
|
||||
}
|
||||
}
|
||||
|
||||
function getGlobalMermaid() {
|
||||
try {
|
||||
const globalStore = globalThis as any
|
||||
return normalizeMermaidModule(globalStore?.[GLOBAL_MERMAID_KEY] ?? globalStore?.mermaid)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setGlobalMermaid(instance: any) {
|
||||
try {
|
||||
;(globalThis as any)[GLOBAL_MERMAID_KEY] = instance
|
||||
}
|
||||
catch {
|
||||
// Ignore global cache writes when the host forbids mutation.
|
||||
}
|
||||
}
|
||||
|
||||
function resetCachedMermaid() {
|
||||
cachedMermaid = null
|
||||
importAttempted = false
|
||||
lastInitKey = null
|
||||
pendingImport = null
|
||||
try {
|
||||
delete (globalThis as any)[GLOBAL_MERMAID_KEY]
|
||||
}
|
||||
catch {
|
||||
// Ignore failures when the global object is sealed.
|
||||
}
|
||||
}
|
||||
|
||||
export function setMermaidLoader(loader: MermaidLoader | null) {
|
||||
mermaidLoader = loader
|
||||
resetCachedMermaid()
|
||||
}
|
||||
|
||||
export function enableMermaid(loader?: MermaidLoader) {
|
||||
setMermaidLoader(loader ?? defaultMermaidLoader)
|
||||
}
|
||||
|
||||
export function disableMermaid() {
|
||||
setMermaidLoader(null)
|
||||
}
|
||||
|
||||
export function isMermaidEnabled() {
|
||||
return typeof mermaidLoader === 'function'
|
||||
}
|
||||
|
||||
export async function getMermaid(initConfig?: Record<string, any>) {
|
||||
if (cachedMermaid) {
|
||||
ensureInitialized(cachedMermaid, initConfig)
|
||||
return cachedMermaid
|
||||
}
|
||||
|
||||
const globalMermaid = getGlobalMermaid()
|
||||
if (globalMermaid) {
|
||||
patchInitialize(globalMermaid)
|
||||
cachedMermaid = globalMermaid
|
||||
setGlobalMermaid(cachedMermaid)
|
||||
ensureInitialized(cachedMermaid, initConfig)
|
||||
return cachedMermaid
|
||||
}
|
||||
|
||||
if (pendingImport) {
|
||||
const instance = await pendingImport
|
||||
if (instance)
|
||||
ensureInitialized(instance, initConfig)
|
||||
return instance
|
||||
}
|
||||
|
||||
if (importAttempted)
|
||||
return null
|
||||
|
||||
const loader = mermaidLoader
|
||||
if (!loader) {
|
||||
importAttempted = true
|
||||
return null
|
||||
}
|
||||
|
||||
pendingImport = (async () => {
|
||||
try {
|
||||
const mod = await loader()
|
||||
const instance = normalizeMermaidModule(mod)
|
||||
if (!instance) {
|
||||
importAttempted = true
|
||||
return null
|
||||
}
|
||||
patchInitialize(instance)
|
||||
cachedMermaid = instance
|
||||
setGlobalMermaid(cachedMermaid)
|
||||
return cachedMermaid
|
||||
}
|
||||
catch {
|
||||
importAttempted = true
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
const instance = await pendingImport
|
||||
if (instance)
|
||||
ensureInitialized(instance, initConfig)
|
||||
return instance
|
||||
}
|
||||
finally {
|
||||
pendingImport = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
let monacoModule: any = null
|
||||
let importAttempted = false
|
||||
let pendingImport: Promise<any | null> | null = null
|
||||
let workersPreloaded = false
|
||||
|
||||
async function preloadWorkers(mod: any) {
|
||||
if (workersPreloaded)
|
||||
return
|
||||
workersPreloaded = true
|
||||
const existingEnv = (globalThis as any)?.MonacoEnvironment
|
||||
if (existingEnv && (typeof existingEnv.getWorker === 'function' || typeof existingEnv.getWorkerUrl === 'function'))
|
||||
return
|
||||
if (typeof mod?.preloadMonacoWorkers === 'function')
|
||||
await mod.preloadMonacoWorkers()
|
||||
}
|
||||
|
||||
async function warmupShikiTokenizer(mod: any) {
|
||||
const getOrCreateHighlighter = mod?.getOrCreateHighlighter
|
||||
if (typeof getOrCreateHighlighter !== 'function')
|
||||
return true
|
||||
|
||||
try {
|
||||
const highlighter = await getOrCreateHighlighter(
|
||||
['vitesse-dark', 'vitesse-light'],
|
||||
['plaintext', 'text', 'javascript'],
|
||||
)
|
||||
|
||||
if (highlighter && typeof highlighter.codeToTokens === 'function') {
|
||||
highlighter.codeToTokens('const a = 1', { lang: 'javascript', theme: 'vitesse-dark' })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
console.warn('[markstream-angular] Failed to warm up stream-monaco tokenizer; falling back to plain code rendering.', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUseMonaco() {
|
||||
if (monacoModule)
|
||||
return monacoModule
|
||||
if (pendingImport)
|
||||
return await pendingImport
|
||||
if (importAttempted)
|
||||
return null
|
||||
|
||||
pendingImport = (async () => {
|
||||
try {
|
||||
const imported: any = await import('stream-monaco')
|
||||
monacoModule = imported?.default ?? imported
|
||||
await preloadWorkers(monacoModule)
|
||||
|
||||
const ready = await warmupShikiTokenizer(monacoModule)
|
||||
if (!ready) {
|
||||
monacoModule = null
|
||||
importAttempted = true
|
||||
return null
|
||||
}
|
||||
|
||||
return monacoModule
|
||||
}
|
||||
catch {
|
||||
importAttempted = true
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
return await pendingImport
|
||||
}
|
||||
finally {
|
||||
pendingImport = null
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import type { BaseNode, MarkdownIt, ParseOptions } from 'stream-markdown-parser'
|
||||
import { getMarkdown, mergeCustomHtmlTags, parseMarkdownToStructure } from 'stream-markdown-parser'
|
||||
import { hydrateCustomTagContent } from './hydrateCustomTagContent'
|
||||
|
||||
export interface NestedMarkdownNodesInput {
|
||||
node?: (BaseNode & Record<string, any>) | null
|
||||
nodes?: readonly BaseNode[] | null
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
export interface NestedMarkdownNodesOptions {
|
||||
cacheKey?: string
|
||||
final?: boolean
|
||||
parseOptions?: ParseOptions
|
||||
customHtmlTags?: readonly string[]
|
||||
customMarkdownIt?: (markdown: MarkdownIt) => MarkdownIt
|
||||
}
|
||||
|
||||
const DEFAULT_CACHE_KEY = 'markstream-angular-nested-nodes'
|
||||
const markdownCache = new Map<string, MarkdownIt>()
|
||||
|
||||
export function parseNestedMarkdownToNodes(
|
||||
input: NestedMarkdownNodesInput,
|
||||
options: NestedMarkdownNodesOptions = {},
|
||||
): BaseNode[] {
|
||||
if (Array.isArray(input.nodes))
|
||||
return input.nodes.slice()
|
||||
|
||||
const nestedNode = input.node
|
||||
if (nestedNode) {
|
||||
const children = getNodeList(nestedNode.children)
|
||||
if (children.length > 0)
|
||||
return children.slice()
|
||||
}
|
||||
|
||||
const content = resolveContent(input)
|
||||
if (!content)
|
||||
return []
|
||||
|
||||
const parseOptions = resolveParseOptions(input, options)
|
||||
const markdown = resolveMarkdownInstance(options)
|
||||
return hydrateCustomTagContent(
|
||||
parseMarkdownToStructure(content, markdown, parseOptions),
|
||||
content,
|
||||
mergeCustomHtmlTags(options.customHtmlTags, (parseOptions as any)?.customHtmlTags),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveContent(input: NestedMarkdownNodesInput) {
|
||||
if (typeof input.content === 'string')
|
||||
return input.content
|
||||
if (typeof input.node?.content === 'string')
|
||||
return input.node.content
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveParseOptions(
|
||||
input: NestedMarkdownNodesInput,
|
||||
options: NestedMarkdownNodesOptions,
|
||||
): ParseOptions | undefined {
|
||||
const base = options.parseOptions ?? {}
|
||||
const resolvedFinal = options.final ?? resolveFinalFromNode(input.node)
|
||||
const customHtmlTags = mergeCustomHtmlTags(options.customHtmlTags, (base as any).customHtmlTags)
|
||||
|
||||
if (resolvedFinal == null && customHtmlTags.length === 0)
|
||||
return base
|
||||
|
||||
return {
|
||||
...(base as any),
|
||||
...(resolvedFinal == null ? {} : { final: resolvedFinal }),
|
||||
...(customHtmlTags.length === 0 ? {} : { customHtmlTags }),
|
||||
} as ParseOptions
|
||||
}
|
||||
|
||||
function resolveFinalFromNode(node?: (BaseNode & Record<string, any>) | null) {
|
||||
if (!node || typeof node !== 'object')
|
||||
return undefined
|
||||
if (typeof node.loading === 'boolean')
|
||||
return !node.loading
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveMarkdownInstance(options: NestedMarkdownNodesOptions) {
|
||||
const normalizedTags = mergeCustomHtmlTags(options.customHtmlTags, (options.parseOptions as any)?.customHtmlTags)
|
||||
const cacheKey = `${options.cacheKey || DEFAULT_CACHE_KEY}::${normalizedTags.join(',')}`
|
||||
let markdown = markdownCache.get(cacheKey)
|
||||
|
||||
if (!markdown) {
|
||||
markdown = getMarkdown(cacheKey, {
|
||||
customHtmlTags: normalizedTags,
|
||||
})
|
||||
markdownCache.set(cacheKey, markdown)
|
||||
}
|
||||
|
||||
return options.customMarkdownIt
|
||||
? options.customMarkdownIt(markdown)
|
||||
: markdown
|
||||
}
|
||||
|
||||
function getNodeList(value: unknown) {
|
||||
return Array.isArray(value) ? value as BaseNode[] : []
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
import type { BaseNode, CustomComponentAttrs, ParsedNode, ParseOptions } from 'stream-markdown-parser'
|
||||
import {
|
||||
DANGEROUS_HTML_ATTRS,
|
||||
getMarkdown,
|
||||
isUnsafeHtmlUrl,
|
||||
NON_STRUCTURING_HTML_TAGS,
|
||||
normalizeCustomHtmlTagName,
|
||||
normalizeCustomHtmlTags,
|
||||
parseMarkdownToStructure,
|
||||
URL_HTML_ATTRS,
|
||||
} from 'stream-markdown-parser'
|
||||
import { hydrateCustomTagContent } from './hydrateCustomTagContent'
|
||||
import { sanitizeHtmlContent } from './sanitizeHtmlContent'
|
||||
|
||||
export type RenderableMarkdownNode = (ParsedNode | BaseNode) & Record<string, unknown>
|
||||
|
||||
type NestedClassValue = string | readonly string[] | null | undefined
|
||||
|
||||
export interface MarkstreamAngularRenderOptions {
|
||||
content?: string | null
|
||||
nodes?: readonly RenderableMarkdownNode[] | null
|
||||
final?: boolean
|
||||
parseOptions?: ParseOptions
|
||||
customHtmlTags?: readonly string[]
|
||||
allowHtml?: boolean
|
||||
}
|
||||
|
||||
export interface NestedMarkdownHtmlOptions {
|
||||
cacheKey?: string
|
||||
customHtmlTags?: readonly string[]
|
||||
allowHtml?: boolean
|
||||
customNodeTag?: string
|
||||
customNodeClass?: NestedClassValue | ((node: RenderableMarkdownNode) => NestedClassValue)
|
||||
}
|
||||
|
||||
export interface NestedMarkdownHtmlInput {
|
||||
node?: RenderableMarkdownNode | null
|
||||
nodes?: readonly RenderableMarkdownNode[] | null
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
interface RenderContext {
|
||||
markdown: ReturnType<typeof getMarkdown>
|
||||
options: Required<Pick<NestedMarkdownHtmlOptions, 'allowHtml' | 'customNodeTag'>> & Pick<NestedMarkdownHtmlOptions, 'customNodeClass'>
|
||||
}
|
||||
|
||||
const DEFAULT_CACHE_KEY = 'markstream-angular-html'
|
||||
const DEFAULT_CUSTOM_NODE_TAG = 'div'
|
||||
const markdownCache = new Map<string, ReturnType<typeof getMarkdown>>()
|
||||
const KNOWN_NODE_TYPES = new Set([
|
||||
'admonition',
|
||||
'blockquote',
|
||||
'checkbox',
|
||||
'checkbox_input',
|
||||
'code_block',
|
||||
'definition_item',
|
||||
'definition_list',
|
||||
'emoji',
|
||||
'emphasis',
|
||||
'footnote',
|
||||
'footnote_reference',
|
||||
'hardbreak',
|
||||
'heading',
|
||||
'highlight',
|
||||
'html_block',
|
||||
'html_inline',
|
||||
'image',
|
||||
'inline_code',
|
||||
'insert',
|
||||
'link',
|
||||
'list',
|
||||
'list_item',
|
||||
'math_block',
|
||||
'math_inline',
|
||||
'paragraph',
|
||||
'reference',
|
||||
'strikethrough',
|
||||
'strong',
|
||||
'subscript',
|
||||
'superscript',
|
||||
'table',
|
||||
'table_cell',
|
||||
'table_row',
|
||||
'text',
|
||||
'thematic_break',
|
||||
])
|
||||
|
||||
export function renderMarkdownToHtml(input: MarkstreamAngularRenderOptions): string {
|
||||
const normalizedTags = normalizeCustomHtmlTags([
|
||||
...(input.customHtmlTags || []),
|
||||
...((((input.parseOptions as any)?.customHtmlTags) || []) as string[]),
|
||||
])
|
||||
const ctx = createRenderContext({
|
||||
allowHtml: input.allowHtml,
|
||||
customHtmlTags: normalizedTags,
|
||||
})
|
||||
const nodes = resolveParsedNodes(input, ctx)
|
||||
return renderNodesToHtml(nodes, ctx)
|
||||
}
|
||||
|
||||
export function renderNestedMarkdownToHtml(
|
||||
input: NestedMarkdownHtmlInput,
|
||||
options: NestedMarkdownHtmlOptions = {},
|
||||
): string {
|
||||
const ctx = createRenderContext(options)
|
||||
return renderNestedInputToHtml(input, ctx)
|
||||
}
|
||||
|
||||
export function renderMarkdownNodesToHtml(
|
||||
nodes: readonly RenderableMarkdownNode[] | null | undefined,
|
||||
options: NestedMarkdownHtmlOptions = {},
|
||||
): string {
|
||||
const ctx = createRenderContext(options)
|
||||
return renderNodesToHtml(nodes, ctx)
|
||||
}
|
||||
|
||||
export function renderMarkdownNodeToHtml(
|
||||
node: RenderableMarkdownNode | null | undefined,
|
||||
options: NestedMarkdownHtmlOptions = {},
|
||||
): string {
|
||||
const ctx = createRenderContext(options)
|
||||
return renderNodeToHtml(node, ctx)
|
||||
}
|
||||
|
||||
function resolveParsedNodes(input: MarkstreamAngularRenderOptions, ctx: RenderContext) {
|
||||
if (Array.isArray(input.nodes))
|
||||
return input.nodes
|
||||
|
||||
const content = getString(input.content)
|
||||
if (!content)
|
||||
return []
|
||||
|
||||
const normalizedTags = normalizeCustomHtmlTags([
|
||||
...(input.customHtmlTags || []),
|
||||
...((((input.parseOptions as any)?.customHtmlTags) || []) as string[]),
|
||||
])
|
||||
const mergedParseOptions: ParseOptions = {
|
||||
...(input.parseOptions ?? {}),
|
||||
}
|
||||
if (typeof input.final === 'boolean')
|
||||
mergedParseOptions.final = input.final
|
||||
if (normalizedTags.length > 0)
|
||||
(mergedParseOptions as any).customHtmlTags = normalizedTags
|
||||
|
||||
return hydrateCustomTagContent(
|
||||
parseMarkdownToStructure(content, ctx.markdown, mergedParseOptions) as RenderableMarkdownNode[],
|
||||
content,
|
||||
normalizedTags,
|
||||
)
|
||||
}
|
||||
|
||||
function createRenderContext(options: NestedMarkdownHtmlOptions): RenderContext {
|
||||
const normalizedTags = normalizeCustomHtmlTags(options.customHtmlTags)
|
||||
const cacheKey = `${options.cacheKey || DEFAULT_CACHE_KEY}::${normalizedTags.join(',')}`
|
||||
let markdown = markdownCache.get(cacheKey)
|
||||
|
||||
if (!markdown) {
|
||||
markdown = getMarkdown(cacheKey, {
|
||||
customHtmlTags: normalizedTags,
|
||||
})
|
||||
markdownCache.set(cacheKey, markdown)
|
||||
}
|
||||
|
||||
return {
|
||||
markdown,
|
||||
options: {
|
||||
allowHtml: options.allowHtml !== false,
|
||||
customNodeTag: normalizeCustomHtmlTagName(options.customNodeTag) || DEFAULT_CUSTOM_NODE_TAG,
|
||||
customNodeClass: options.customNodeClass,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderNestedInputToHtml(input: NestedMarkdownHtmlInput, ctx: RenderContext): string {
|
||||
if (Array.isArray(input.nodes) && input.nodes.length > 0)
|
||||
return renderNodesToHtml(input.nodes, ctx)
|
||||
|
||||
const node = input.node
|
||||
if (node) {
|
||||
const children = getNodeList(node.children)
|
||||
if (children.length > 0)
|
||||
return renderNodesToHtml(children, ctx)
|
||||
|
||||
const content = getString(node.content)
|
||||
if (content)
|
||||
return renderMarkdownFragment(content, ctx)
|
||||
|
||||
if (node.raw != null)
|
||||
return escapeHtml(getString(node.raw))
|
||||
}
|
||||
|
||||
return input.content ? renderMarkdownFragment(getString(input.content), ctx) : ''
|
||||
}
|
||||
|
||||
function renderNodesToHtml(nodes: readonly RenderableMarkdownNode[] | null | undefined, ctx: RenderContext): string {
|
||||
return (Array.isArray(nodes) ? nodes : []).map(node => renderNodeToHtml(node, ctx)).join('')
|
||||
}
|
||||
|
||||
function renderNodeToHtml(node: RenderableMarkdownNode | null | undefined, ctx: RenderContext): string {
|
||||
if (!node || typeof node !== 'object')
|
||||
return ''
|
||||
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return renderTextNode(node)
|
||||
case 'paragraph':
|
||||
return `<p>${renderNodesToHtml(getNodeList(node.children), ctx)}</p>`
|
||||
case 'strong':
|
||||
return `<strong>${renderNodesToHtml(getNodeList(node.children), ctx)}</strong>`
|
||||
case 'emphasis':
|
||||
return `<em>${renderNodesToHtml(getNodeList(node.children), ctx)}</em>`
|
||||
case 'strikethrough':
|
||||
return `<del>${renderNodesToHtml(getNodeList(node.children), ctx)}</del>`
|
||||
case 'highlight':
|
||||
return `<mark>${renderNodesToHtml(getNodeList(node.children), ctx)}</mark>`
|
||||
case 'insert':
|
||||
return `<ins>${renderNodesToHtml(getNodeList(node.children), ctx)}</ins>`
|
||||
case 'subscript':
|
||||
return `<sub>${renderNodesToHtml(getNodeList(node.children), ctx)}</sub>`
|
||||
case 'superscript':
|
||||
return `<sup>${renderNodesToHtml(getNodeList(node.children), ctx)}</sup>`
|
||||
case 'inline_code':
|
||||
return `<code>${escapeHtml(getString(node.code))}</code>`
|
||||
case 'hardbreak':
|
||||
return '<br>'
|
||||
case 'link':
|
||||
return renderLinkNode(node, ctx)
|
||||
case 'image':
|
||||
return renderImageNode(node)
|
||||
case 'list':
|
||||
return renderListNode(node, ctx)
|
||||
case 'list_item':
|
||||
return `<li>${renderNodesToHtml(getNodeList(node.children), ctx)}</li>`
|
||||
case 'blockquote':
|
||||
return `<blockquote>${renderNodesToHtml(getNodeList(node.children), ctx)}</blockquote>`
|
||||
case 'heading':
|
||||
return renderHeadingNode(node, ctx)
|
||||
case 'code_block':
|
||||
return renderCodeBlockNode(node)
|
||||
case 'thematic_break':
|
||||
return '<hr>'
|
||||
case 'table':
|
||||
return renderTableNode(node, ctx)
|
||||
case 'table_row':
|
||||
return renderTableRowNode(node, ctx)
|
||||
case 'table_cell':
|
||||
return renderTableCellNode(node, ctx)
|
||||
case 'definition_list':
|
||||
return renderDefinitionListNode(node, ctx)
|
||||
case 'definition_item':
|
||||
return renderDefinitionItemNode(node, ctx)
|
||||
case 'footnote':
|
||||
return renderFootnoteNode(node, ctx)
|
||||
case 'footnote_reference':
|
||||
return renderFootnoteReferenceNode(node)
|
||||
case 'admonition':
|
||||
return renderAdmonitionNode(node, ctx)
|
||||
case 'checkbox':
|
||||
case 'checkbox_input':
|
||||
return `<input type="checkbox" disabled${node.checked ? ' checked' : ''}>`
|
||||
case 'emoji':
|
||||
return escapeHtml(getString(node.raw || node.markup || node.content || node.name))
|
||||
case 'math_inline':
|
||||
return renderMathInlineNode(node)
|
||||
case 'math_block':
|
||||
return renderMathBlockNode(node)
|
||||
case 'reference':
|
||||
return `<span class="markstream-nested-reference">${escapeHtml(getString(node.id))}</span>`
|
||||
case 'html_inline':
|
||||
case 'html_block':
|
||||
return renderHtmlNode(node, ctx)
|
||||
default:
|
||||
return renderCustomOrFallbackNode(node, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
function renderTextNode(node: RenderableMarkdownNode): string {
|
||||
const content = getString(node.content)
|
||||
const escaped = escapeHtml(content)
|
||||
const centered = !!node.center
|
||||
if (!centered && !content.includes('\n'))
|
||||
return escaped
|
||||
|
||||
const className = centered
|
||||
? 'markstream-angular-text-node markstream-angular-text--centered'
|
||||
: 'markstream-angular-text-node'
|
||||
return `<span class="${className}">${escaped}</span>`
|
||||
}
|
||||
|
||||
function renderLinkNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
const href = getString(node.href)
|
||||
const title = getString(node.title)
|
||||
const content = getNodeList(node.children).length > 0
|
||||
? renderNodesToHtml(getNodeList(node.children), ctx)
|
||||
: escapeHtml(getString(node.text || href))
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''
|
||||
const hrefAttr = href ? ` href="${escapeAttr(href)}"` : ''
|
||||
return `<a${hrefAttr}${titleAttr} target="_blank" rel="noreferrer noopener">${content}</a>`
|
||||
}
|
||||
|
||||
function renderMathInlineNode(node: RenderableMarkdownNode) {
|
||||
const source = escapeHtml(getString(node.content || node.markup || node.raw))
|
||||
return `<span class="markstream-nested-math" data-display="inline"><span class="markstream-nested-math__source">${source}</span><span class="markstream-nested-math__render" aria-hidden="true"></span></span>`
|
||||
}
|
||||
|
||||
function renderMathBlockNode(node: RenderableMarkdownNode) {
|
||||
const source = escapeHtml(getString(node.content || node.markup || node.raw))
|
||||
return `<div class="markstream-nested-math-block"><pre class="markstream-nested-math-block__source"><code>${source}</code></pre><div class="markstream-nested-math-block__render" aria-hidden="true"></div></div>`
|
||||
}
|
||||
|
||||
function renderImageNode(node: RenderableMarkdownNode): string {
|
||||
const src = getString(node.src)
|
||||
const alt = getString(node.alt)
|
||||
const title = getString(node.title)
|
||||
const titleAttr = title ? ` title="${escapeAttr(title)}"` : ''
|
||||
return `<img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${titleAttr}>`
|
||||
}
|
||||
|
||||
function renderListNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
const tag = node.ordered ? 'ol' : 'ul'
|
||||
const start = node.ordered && Number.isFinite(node.start) ? ` start="${Number(node.start)}"` : ''
|
||||
return `<${tag}${start}>${renderNodesToHtml(getNodeList(node.items), ctx)}</${tag}>`
|
||||
}
|
||||
|
||||
function renderHeadingNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
const level = clampHeadingLevel(node.level)
|
||||
return `<h${level}>${renderNodesToHtml(getNodeList(node.children), ctx)}</h${level}>`
|
||||
}
|
||||
|
||||
function renderCodeBlockNode(node: RenderableMarkdownNode): string {
|
||||
const rawLanguage = getString(node.language).trim()
|
||||
const language = sanitizeClassToken(rawLanguage)
|
||||
const languageClass = language ? ` class="language-${language}"` : ''
|
||||
const diff = !!node.diff
|
||||
const blockAttrs = [
|
||||
'data-markstream-code-block="1"',
|
||||
rawLanguage ? `data-markstream-language="${escapeAttr(rawLanguage)}"` : '',
|
||||
diff ? 'data-markstream-diff="1"' : '',
|
||||
diff ? `data-markstream-original="${escapeAttr(encodeDataPayload(getString(node.originalCode)))}"` : '',
|
||||
diff ? `data-markstream-updated="${escapeAttr(encodeDataPayload(getString(node.updatedCode)))}"` : '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return `<pre ${blockAttrs}><code${languageClass}>${escapeHtml(getString(node.code))}</code></pre>`
|
||||
}
|
||||
|
||||
function renderTableNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
const header = node.header ? renderTableRowNode(node.header as RenderableMarkdownNode, ctx, true) : ''
|
||||
const rows = renderNodesToHtml(getNodeList(node.rows), ctx)
|
||||
const thead = header ? `<thead>${header}</thead>` : ''
|
||||
const tbody = rows ? `<tbody>${rows}</tbody>` : ''
|
||||
return `<table>${thead}${tbody}</table>`
|
||||
}
|
||||
|
||||
function renderTableRowNode(node: RenderableMarkdownNode, ctx: RenderContext, forceHeader = false): string {
|
||||
const cells = getNodeList(node.cells).map(cell => renderTableCellNode(cell, ctx, forceHeader)).join('')
|
||||
return `<tr>${cells}</tr>`
|
||||
}
|
||||
|
||||
function renderTableCellNode(node: RenderableMarkdownNode, ctx: RenderContext, forceHeader = false): string {
|
||||
const tag = forceHeader || !!node.header ? 'th' : 'td'
|
||||
const align = getString(node.align)
|
||||
const alignAttr = align ? ` style="text-align:${escapeAttr(align)}"` : ''
|
||||
return `<${tag}${alignAttr}>${renderNodesToHtml(getNodeList(node.children), ctx)}</${tag}>`
|
||||
}
|
||||
|
||||
function renderDefinitionListNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
return `<dl>${getNodeList(node.items).map(item => renderDefinitionItemNode(item, ctx)).join('')}</dl>`
|
||||
}
|
||||
|
||||
function renderDefinitionItemNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
const term = renderNodesToHtml(getNodeList(node.term), ctx)
|
||||
const definition = renderNodesToHtml(getNodeList(node.definition), ctx)
|
||||
return `<dt>${term}</dt><dd>${definition}</dd>`
|
||||
}
|
||||
|
||||
function renderFootnoteNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
const id = escapeAttr(getString(node.id))
|
||||
return `<div id="fnref--${id}" class="markstream-nested-footnote">${renderNodesToHtml(getNodeList(node.children), ctx)}</div>`
|
||||
}
|
||||
|
||||
function renderFootnoteReferenceNode(node: RenderableMarkdownNode): string {
|
||||
const id = escapeHtml(getString(node.id))
|
||||
const href = escapeAttr(getString(node.id))
|
||||
return `<sup class="markstream-nested-footnote-ref"><a href="#fnref--${href}">[${id}]</a></sup>`
|
||||
}
|
||||
|
||||
function renderAdmonitionNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
const kind = sanitizeClassToken(getString(node.kind || 'note')) || 'note'
|
||||
const title = getString(node.title) || capitalize(kind)
|
||||
return `<div class="markstream-nested-admonition markstream-nested-admonition--${kind}"><div class="markstream-nested-admonition__title">${escapeHtml(title)}</div><div class="markstream-nested-admonition__content">${renderNodesToHtml(getNodeList(node.children), ctx)}</div></div>`
|
||||
}
|
||||
|
||||
function renderHtmlNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
const content = getString(node.content)
|
||||
const rawContent = content || getString(node.raw)
|
||||
const tag = getString(node.tag).trim().toLowerCase()
|
||||
const children = getNodeList(node.children)
|
||||
if (!ctx.options.allowHtml)
|
||||
return escapeHtml(rawContent)
|
||||
if (node.loading && !node.autoClosed)
|
||||
return escapeHtml(rawContent)
|
||||
if (tag && children.length > 0 && !NON_STRUCTURING_HTML_TAGS.has(tag)) {
|
||||
const attrs = serializeAttrs(node.attrs as CustomComponentAttrs | undefined)
|
||||
return `<${tag}${attrs}>${renderNodesToHtml(children, ctx)}</${tag}>`
|
||||
}
|
||||
return sanitizeHtmlContent(rawContent)
|
||||
}
|
||||
|
||||
function renderCustomOrFallbackNode(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
const tagName = getString(node.tag || node.type)
|
||||
if (!tagName || KNOWN_NODE_TYPES.has(tagName))
|
||||
return escapeHtml(getString(node.content || node.raw))
|
||||
|
||||
const classes = [
|
||||
'markstream-nested-custom',
|
||||
`markstream-nested-custom--${sanitizeClassToken(tagName) || 'node'}`,
|
||||
resolveCustomNodeClass(node, ctx.options.customNodeClass),
|
||||
].filter(Boolean).join(' ')
|
||||
const attrs = serializeAttrs(node.attrs as CustomComponentAttrs | undefined, classes)
|
||||
const body = resolveCustomNodeBody(node, ctx)
|
||||
const wrapperTag = ctx.options.customNodeTag
|
||||
return `<${wrapperTag}${attrs} data-markstream-custom-tag="${escapeAttr(tagName)}">${body}</${wrapperTag}>`
|
||||
}
|
||||
|
||||
function resolveCustomNodeBody(node: RenderableMarkdownNode, ctx: RenderContext): string {
|
||||
const children = getNodeList(node.children)
|
||||
if (children.length > 0)
|
||||
return renderNodesToHtml(children, ctx)
|
||||
|
||||
const content = getString(node.content)
|
||||
if (content)
|
||||
return renderMarkdownFragment(content, ctx)
|
||||
|
||||
return escapeHtml(getString(node.raw))
|
||||
}
|
||||
|
||||
function renderMarkdownFragment(content: string, ctx: RenderContext): string {
|
||||
return content ? ctx.markdown.render(content) : ''
|
||||
}
|
||||
|
||||
function resolveCustomNodeClass(
|
||||
node: RenderableMarkdownNode,
|
||||
customNodeClass?: NestedMarkdownHtmlOptions['customNodeClass'],
|
||||
): string {
|
||||
if (!customNodeClass)
|
||||
return ''
|
||||
const resolved = typeof customNodeClass === 'function'
|
||||
? customNodeClass(node)
|
||||
: customNodeClass
|
||||
return serializeClassValue(resolved)
|
||||
}
|
||||
|
||||
function serializeClassValue(value: NestedClassValue): string {
|
||||
if (Array.isArray(value))
|
||||
return value.map(item => serializeClassValue(item)).filter(Boolean).join(' ')
|
||||
return typeof value === 'string'
|
||||
? value.trim()
|
||||
: ''
|
||||
}
|
||||
|
||||
function serializeAttrs(attrs?: CustomComponentAttrs | null, extraClass = ''): string {
|
||||
const pairs = normalizeAttrs(attrs)
|
||||
const rendered: string[] = []
|
||||
const mergedClasses = [extraClass]
|
||||
|
||||
for (const [name, value] of pairs) {
|
||||
const safeName = String(name).trim()
|
||||
const lowerName = safeName.toLowerCase()
|
||||
if (!safeName || !isSafeAttrName(safeName))
|
||||
continue
|
||||
if (DANGEROUS_HTML_ATTRS.has(lowerName))
|
||||
continue
|
||||
if (value !== true && URL_HTML_ATTRS.has(lowerName) && value && isUnsafeHtmlUrl(String(value)))
|
||||
continue
|
||||
if (lowerName === 'class') {
|
||||
mergedClasses.push(String(value))
|
||||
continue
|
||||
}
|
||||
if (value === true)
|
||||
rendered.push(` ${safeName}`)
|
||||
else
|
||||
rendered.push(` ${safeName}="${escapeAttr(String(value))}"`)
|
||||
}
|
||||
|
||||
const className = mergedClasses.map(value => value.trim()).filter(Boolean).join(' ')
|
||||
if (className)
|
||||
rendered.unshift(` class="${escapeAttr(className)}"`)
|
||||
return rendered.join('')
|
||||
}
|
||||
|
||||
function normalizeAttrs(attrs?: CustomComponentAttrs | null): Array<[string, string | boolean]> {
|
||||
if (!attrs)
|
||||
return []
|
||||
|
||||
if (Array.isArray(attrs)) {
|
||||
if (attrs.length === 0)
|
||||
return []
|
||||
const first = attrs[0]
|
||||
if (Array.isArray(first))
|
||||
return (attrs as [string, string][]).map(([name, value]) => [String(name), value])
|
||||
return (attrs as Array<{ name: string, value: string | boolean }>).map(item => [String(item.name), item.value])
|
||||
}
|
||||
|
||||
return Object.entries(attrs).map(([name, value]) => [name, value])
|
||||
}
|
||||
|
||||
function getNodeList(value: unknown): RenderableMarkdownNode[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is RenderableMarkdownNode => !!item && typeof item === 'object')
|
||||
: []
|
||||
}
|
||||
|
||||
function getString(value: unknown): string {
|
||||
return typeof value === 'string'
|
||||
? value
|
||||
: value == null
|
||||
? ''
|
||||
: String(value)
|
||||
}
|
||||
|
||||
function isSafeAttrName(value: string): boolean {
|
||||
return /^[^\s"'<>`=]+$/.test(value) && !/^on/i.test(value)
|
||||
}
|
||||
|
||||
function sanitizeClassToken(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function clampHeadingLevel(value: unknown): number {
|
||||
const level = Math.trunc(Number(value) || 1)
|
||||
return Math.min(6, Math.max(1, level))
|
||||
}
|
||||
|
||||
function escapeHtml(value: unknown): string {
|
||||
return getString(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function escapeAttr(value: unknown): string {
|
||||
return escapeHtml(value).replace(/`/g, '`')
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value ? `${value[0].toUpperCase()}${value.slice(1)}` : ''
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
BLOCKED_HTML_TAGS as BLOCKED_TAGS,
|
||||
DANGEROUS_HTML_ATTRS as DANGEROUS_ATTRS,
|
||||
isUnsafeHtmlUrl as isUnsafeUrl,
|
||||
URL_HTML_ATTRS as URL_ATTRS,
|
||||
VOID_HTML_TAGS as VOID_ELEMENTS,
|
||||
} from 'stream-markdown-parser'
|
||||
import { escapeAttr, escapeHtml, isSafeAttrName } from './components/shared/node-helpers'
|
||||
|
||||
interface HtmlToken {
|
||||
type: 'text' | 'tag_open' | 'tag_close' | 'self_closing'
|
||||
tagName?: string
|
||||
attrs?: Record<string, string>
|
||||
content?: string
|
||||
}
|
||||
|
||||
function sanitizeAttrs(attrs: Record<string, string>): Record<string, string> {
|
||||
const clean: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
const safeName = key.trim()
|
||||
const lowerKey = safeName.toLowerCase()
|
||||
if (!safeName || !isSafeAttrName(safeName))
|
||||
continue
|
||||
if (DANGEROUS_ATTRS.has(lowerKey))
|
||||
continue
|
||||
if (URL_ATTRS.has(lowerKey) && value && isUnsafeUrl(value))
|
||||
continue
|
||||
clean[safeName] = value
|
||||
}
|
||||
|
||||
return clean
|
||||
}
|
||||
|
||||
function tokenizeHtml(html: string): HtmlToken[] {
|
||||
const tokens: HtmlToken[] = []
|
||||
let pos = 0
|
||||
|
||||
while (pos < html.length) {
|
||||
if (html.startsWith('<!--', pos)) {
|
||||
const commentEnd = html.indexOf('-->', pos)
|
||||
if (commentEnd !== -1) {
|
||||
pos = commentEnd + 3
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const tagStart = html.indexOf('<', pos)
|
||||
if (tagStart === -1) {
|
||||
if (pos < html.length)
|
||||
tokens.push({ type: 'text', content: html.slice(pos) })
|
||||
break
|
||||
}
|
||||
|
||||
if (tagStart > pos)
|
||||
tokens.push({ type: 'text', content: html.slice(pos, tagStart) })
|
||||
|
||||
if (html.startsWith('![CDATA[', tagStart + 1)) {
|
||||
const cdataEnd = html.indexOf(']]>', tagStart)
|
||||
if (cdataEnd !== -1) {
|
||||
tokens.push({ type: 'text', content: html.slice(tagStart, cdataEnd + 3) })
|
||||
pos = cdataEnd + 3
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (html.startsWith('!', tagStart + 1)) {
|
||||
const specialEnd = html.indexOf('>', tagStart)
|
||||
if (specialEnd !== -1) {
|
||||
pos = specialEnd + 1
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const tagEnd = html.indexOf('>', tagStart)
|
||||
if (tagEnd === -1)
|
||||
break
|
||||
|
||||
const tagContent = html.slice(tagStart + 1, tagEnd).trim()
|
||||
if (!tagContent) {
|
||||
pos = tagEnd + 1
|
||||
continue
|
||||
}
|
||||
|
||||
const isClosingTag = tagContent.startsWith('/')
|
||||
const isSelfClosing = tagContent.endsWith('/')
|
||||
|
||||
if (isClosingTag) {
|
||||
const tagName = tagContent.slice(1).trim()
|
||||
tokens.push({ type: 'tag_close', tagName })
|
||||
pos = tagEnd + 1
|
||||
continue
|
||||
}
|
||||
|
||||
const spaceIndex = tagContent.indexOf(' ')
|
||||
let tagName = ''
|
||||
let attrsStr = ''
|
||||
if (spaceIndex === -1) {
|
||||
tagName = isSelfClosing ? tagContent.slice(0, -1).trim() : tagContent.trim()
|
||||
}
|
||||
else {
|
||||
tagName = tagContent.slice(0, spaceIndex).trim()
|
||||
attrsStr = tagContent.slice(spaceIndex + 1)
|
||||
}
|
||||
|
||||
const attrs: Record<string, string> = {}
|
||||
if (attrsStr) {
|
||||
const attrRegex = /([^\s=]+)(?:=(?:"([^"]*)"|'([^']*)'|(\S*)))?/g
|
||||
let attrMatch: RegExpExecArray | null
|
||||
while ((attrMatch = attrRegex.exec(attrsStr)) !== null) {
|
||||
const name = attrMatch[1]
|
||||
const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? ''
|
||||
if (name && !name.endsWith('/'))
|
||||
attrs[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push({
|
||||
type: isSelfClosing || VOID_ELEMENTS.has(tagName.toLowerCase()) ? 'self_closing' : 'tag_open',
|
||||
tagName,
|
||||
attrs,
|
||||
})
|
||||
|
||||
pos = tagEnd + 1
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function normalizeTagName(tagName: string | undefined): string {
|
||||
return String(tagName ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function serializeAttrs(attrs: Record<string, string>): string {
|
||||
const pairs = Object.entries(attrs)
|
||||
if (pairs.length === 0)
|
||||
return ''
|
||||
|
||||
return pairs
|
||||
.map(([name, value]) => value === '' ? ` ${name}` : ` ${name}="${escapeAttr(value)}"`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function sanitizeHtmlContent(content: string): string {
|
||||
if (!content)
|
||||
return ''
|
||||
|
||||
const tokens = tokenizeHtml(content)
|
||||
const stack: string[] = []
|
||||
const output: string[] = []
|
||||
let blockedDepth = 0
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.type === 'text') {
|
||||
if (blockedDepth === 0)
|
||||
output.push(escapeHtml(token.content ?? ''))
|
||||
continue
|
||||
}
|
||||
|
||||
const tagName = normalizeTagName(token.tagName)
|
||||
if (!tagName)
|
||||
continue
|
||||
|
||||
if (BLOCKED_TAGS.has(tagName)) {
|
||||
if (token.type === 'tag_open')
|
||||
blockedDepth += 1
|
||||
else if (token.type === 'tag_close' && blockedDepth > 0)
|
||||
blockedDepth -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (blockedDepth > 0)
|
||||
continue
|
||||
|
||||
if (token.type === 'self_closing') {
|
||||
output.push(`<${tagName}${serializeAttrs(sanitizeAttrs(token.attrs ?? {}))}>`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (token.type === 'tag_open') {
|
||||
output.push(`<${tagName}${serializeAttrs(sanitizeAttrs(token.attrs ?? {}))}>`)
|
||||
if (!VOID_ELEMENTS.has(tagName))
|
||||
stack.push(tagName)
|
||||
continue
|
||||
}
|
||||
|
||||
const matchedIndex = stack.lastIndexOf(tagName)
|
||||
if (matchedIndex === -1)
|
||||
continue
|
||||
|
||||
while (stack.length > matchedIndex + 1) {
|
||||
const danglingTag = stack.pop()
|
||||
if (danglingTag)
|
||||
output.push(`</${danglingTag}>`)
|
||||
}
|
||||
|
||||
const closingTag = stack.pop()
|
||||
if (closingTag)
|
||||
output.push(`</${closingTag}>`)
|
||||
}
|
||||
|
||||
while (stack.length > 0) {
|
||||
const danglingTag = stack.pop()
|
||||
if (danglingTag)
|
||||
output.push(`</${danglingTag}>`)
|
||||
}
|
||||
|
||||
return output.join('')
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
const DISALLOWED_STYLE_PATTERNS = [/javascript:/i, /expression\s*\(/i, /url\s*\(\s*javascript:/i, /@import/i]
|
||||
const SAFE_URL_PROTOCOLS = /^(?:https?:|mailto:|tel:|#|\/|data:image\/(?:png|gif|jpe?g|webp);)/i
|
||||
|
||||
function neutralizeScriptProtocols(raw: string) {
|
||||
return raw
|
||||
.replace(/["']\s*javascript:/gi, '#')
|
||||
.replace(/\bjavascript:/gi, '#')
|
||||
.replace(/["']\s*vbscript:/gi, '#')
|
||||
.replace(/\bvbscript:/gi, '#')
|
||||
.replace(/\bdata:text\/html/gi, '#')
|
||||
}
|
||||
|
||||
function sanitizeUrl(value: string | null | undefined) {
|
||||
if (!value)
|
||||
return ''
|
||||
const trimmed = value.trim()
|
||||
if (SAFE_URL_PROTOCOLS.test(trimmed))
|
||||
return trimmed
|
||||
return ''
|
||||
}
|
||||
|
||||
function scrubSvgElement(svgEl: SVGElement) {
|
||||
const forbiddenTags = new Set(['script'])
|
||||
const nodes = [svgEl, ...Array.from(svgEl.querySelectorAll<SVGElement>('*'))]
|
||||
for (const node of nodes) {
|
||||
if (forbiddenTags.has(node.tagName.toLowerCase())) {
|
||||
node.remove()
|
||||
continue
|
||||
}
|
||||
|
||||
const attrs = Array.from(node.attributes)
|
||||
for (const attr of attrs) {
|
||||
const name = attr.name
|
||||
if (/^on/i.test(name)) {
|
||||
node.removeAttribute(name)
|
||||
continue
|
||||
}
|
||||
|
||||
if (name === 'style' && attr.value) {
|
||||
if (DISALLOWED_STYLE_PATTERNS.some(re => re.test(attr.value))) {
|
||||
node.removeAttribute(name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if ((name === 'href' || name === 'xlink:href') && attr.value) {
|
||||
const safe = sanitizeUrl(attr.value)
|
||||
if (!safe) {
|
||||
node.removeAttribute(name)
|
||||
continue
|
||||
}
|
||||
if (safe !== attr.value)
|
||||
node.setAttribute(name, safe)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function toSafeSvgMarkup(svg: string | null | undefined) {
|
||||
if (typeof DOMParser === 'undefined')
|
||||
return ''
|
||||
if (!svg)
|
||||
return ''
|
||||
|
||||
const neutralized = neutralizeScriptProtocols(svg)
|
||||
const parsed = new DOMParser().parseFromString(neutralized, 'image/svg+xml')
|
||||
const svgEl = parsed.documentElement
|
||||
if (!svgEl || svgEl.nodeName.toLowerCase() !== 'svg')
|
||||
return ''
|
||||
|
||||
const svgElement = svgEl as unknown as SVGElement
|
||||
scrubSvgElement(svgElement)
|
||||
return svgElement.outerHTML
|
||||
}
|
||||
|
||||
export function extractRenderedSvg(renderResult: any) {
|
||||
if (!renderResult)
|
||||
return ''
|
||||
if (typeof renderResult === 'string')
|
||||
return renderResult
|
||||
if (typeof renderResult.svg === 'string')
|
||||
return renderResult.svg
|
||||
if (typeof renderResult.data === 'string')
|
||||
return renderResult.data
|
||||
return ''
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
|
||||
|
||||
export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
let tooltipEl: HTMLDivElement | null = null
|
||||
let currentAnchor: HTMLElement | null = null
|
||||
let cleanupAutoUpdate: (() => void) | null = null
|
||||
let showTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let hideTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let currentId: string | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (showTimer) {
|
||||
clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTooltipEl() {
|
||||
if (tooltipEl || typeof document === 'undefined')
|
||||
return tooltipEl
|
||||
|
||||
tooltipEl = document.createElement('div')
|
||||
tooltipEl.className = 'ms-tooltip'
|
||||
tooltipEl.setAttribute('role', 'tooltip')
|
||||
tooltipEl.dataset.visible = 'false'
|
||||
tooltipEl.style.position = 'fixed'
|
||||
tooltipEl.style.left = '0px'
|
||||
tooltipEl.style.top = '0px'
|
||||
tooltipEl.style.transform = 'translate3d(0,0,0)'
|
||||
document.body.appendChild(tooltipEl)
|
||||
return tooltipEl
|
||||
}
|
||||
|
||||
function detectDarkModeHint(hint?: boolean | null) {
|
||||
if (typeof hint === 'boolean')
|
||||
return hint
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
try {
|
||||
if (document.documentElement.classList.contains('dark'))
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
// Ignore classList failures on locked-down documents.
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||||
try {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
catch {
|
||||
// Ignore matchMedia failures in non-browser environments.
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function updateTooltipPosition(placement: TooltipPlacement) {
|
||||
if (!tooltipEl || !currentAnchor)
|
||||
return
|
||||
|
||||
const { x, y } = await computePosition(currentAnchor, tooltipEl, {
|
||||
placement,
|
||||
middleware: [offset(8), flip(), shift({ padding: 8 })],
|
||||
strategy: 'fixed',
|
||||
})
|
||||
|
||||
tooltipEl.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`
|
||||
}
|
||||
|
||||
export function showTooltipForAnchor(
|
||||
anchor: HTMLElement | null,
|
||||
content: string,
|
||||
placement: TooltipPlacement = 'top',
|
||||
immediate = false,
|
||||
isDark?: boolean | null,
|
||||
) {
|
||||
if (!anchor || typeof document === 'undefined')
|
||||
return
|
||||
|
||||
const element = ensureTooltipEl()
|
||||
if (!element)
|
||||
return
|
||||
|
||||
clearTimers()
|
||||
const doShow = async () => {
|
||||
if (!tooltipEl)
|
||||
return
|
||||
|
||||
currentAnchor = anchor
|
||||
tooltipEl.textContent = content
|
||||
tooltipEl.dataset.placement = placement
|
||||
tooltipEl.dataset.dark = detectDarkModeHint(isDark) ? 'true' : 'false'
|
||||
tooltipEl.dataset.visible = 'false'
|
||||
currentId = `tooltip-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
tooltipEl.id = currentId
|
||||
|
||||
try {
|
||||
anchor.setAttribute('aria-describedby', currentId)
|
||||
}
|
||||
catch {
|
||||
// Ignore aria sync failures.
|
||||
}
|
||||
|
||||
await updateTooltipPosition(placement)
|
||||
tooltipEl.dataset.visible = 'true'
|
||||
cleanupAutoUpdate?.()
|
||||
cleanupAutoUpdate = autoUpdate(anchor, tooltipEl, () => {
|
||||
void updateTooltipPosition(placement)
|
||||
})
|
||||
}
|
||||
|
||||
if (immediate)
|
||||
void doShow()
|
||||
else
|
||||
showTimer = setTimeout(doShow, 80)
|
||||
}
|
||||
|
||||
export function hideTooltip(immediate = false) {
|
||||
if (!tooltipEl)
|
||||
return
|
||||
|
||||
clearTimers()
|
||||
const doHide = () => {
|
||||
if (!tooltipEl)
|
||||
return
|
||||
|
||||
tooltipEl.dataset.visible = 'false'
|
||||
if (currentAnchor && currentId) {
|
||||
try {
|
||||
currentAnchor.removeAttribute('aria-describedby')
|
||||
}
|
||||
catch {
|
||||
// Ignore aria sync failures.
|
||||
}
|
||||
}
|
||||
currentAnchor = null
|
||||
currentId = null
|
||||
cleanupAutoUpdate?.()
|
||||
cleanupAutoUpdate = null
|
||||
}
|
||||
|
||||
if (immediate)
|
||||
doHide()
|
||||
else
|
||||
hideTimer = setTimeout(doHide, 120)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
export interface CodeBlockMonacoThemeObject {
|
||||
name: string
|
||||
base?: string
|
||||
inherit?: boolean
|
||||
colors?: Record<string, string>
|
||||
rules?: Array<Record<string, unknown>>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type CodeBlockMonacoTheme = string | CodeBlockMonacoThemeObject
|
||||
|
||||
export type CodeBlockMonacoLanguage = string | ((...args: any[]) => unknown)
|
||||
|
||||
export interface CodeBlockDiffHideUnchangedRegionsOptions {
|
||||
enabled?: boolean
|
||||
contextLineCount?: number
|
||||
minimumLineCount?: number
|
||||
revealLineCount?: number
|
||||
}
|
||||
|
||||
export type CodeBlockDiffHideUnchangedRegions
|
||||
= | boolean
|
||||
| CodeBlockDiffHideUnchangedRegionsOptions
|
||||
|
||||
export type CodeBlockDiffLineStyle = 'background' | 'bar'
|
||||
|
||||
export type CodeBlockDiffAppearance = 'auto' | 'light' | 'dark'
|
||||
|
||||
export type CodeBlockDiffUnchangedRegionStyle = 'line-info' | 'line-info-basic' | 'metadata' | 'simple'
|
||||
|
||||
export type CodeBlockDiffHunkActionKind = 'revert' | 'stage'
|
||||
|
||||
export type CodeBlockDiffHunkSide = 'upper' | 'lower'
|
||||
|
||||
export interface CodeBlockDiffHunkActionContext {
|
||||
action: CodeBlockDiffHunkActionKind
|
||||
side: CodeBlockDiffHunkSide
|
||||
lineChange: unknown
|
||||
originalModel: unknown
|
||||
modifiedModel: unknown
|
||||
}
|
||||
|
||||
export interface CodeBlockMonacoOptions {
|
||||
MAX_HEIGHT?: number | string
|
||||
fontSize?: number
|
||||
lineHeight?: number
|
||||
fontFamily?: string
|
||||
tabSize?: number
|
||||
readOnly?: boolean
|
||||
wordWrap?: 'off' | 'on' | 'wordWrapColumn' | 'bounded' | string
|
||||
wrappingIndent?: 'none' | 'same' | 'indent' | 'deepIndent' | string
|
||||
theme?: string
|
||||
themes?: CodeBlockMonacoTheme[]
|
||||
languages?: CodeBlockMonacoLanguage[]
|
||||
renderSideBySide?: boolean
|
||||
useInlineViewWhenSpaceIsLimited?: boolean
|
||||
enableSplitViewResizing?: boolean
|
||||
ignoreTrimWhitespace?: boolean
|
||||
maxComputationTime?: number
|
||||
diffAlgorithm?: string
|
||||
renderIndicators?: boolean
|
||||
originalEditable?: boolean
|
||||
revealDebounceMs?: number
|
||||
revealStrategy?: 'bottom' | 'centerIfOutside' | 'center'
|
||||
revealBatchOnIdleMs?: number
|
||||
updateThrottleMs?: number
|
||||
diffUpdateThrottleMs?: number
|
||||
diffAutoScroll?: boolean
|
||||
diffHideUnchangedRegions?: CodeBlockDiffHideUnchangedRegions
|
||||
diffLineStyle?: CodeBlockDiffLineStyle
|
||||
diffAppearance?: CodeBlockDiffAppearance
|
||||
diffUnchangedRegionStyle?: CodeBlockDiffUnchangedRegionStyle
|
||||
diffHunkActionsOnHover?: boolean
|
||||
diffHunkHoverHideDelayMs?: number
|
||||
onDiffHunkAction?: (context: CodeBlockDiffHunkActionContext) => void | boolean | Promise<void | boolean>
|
||||
scrollbar?: Record<string, any>
|
||||
[key: string]: any
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
export type LanguageIconResolver = (lang: string) => string | undefined | null
|
||||
|
||||
const DEFAULT_ICON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M14 3h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5" />
|
||||
<path d="M8 11l-3 3l3 3" />
|
||||
<path d="M16 11l3 3l-3 3" />
|
||||
<path d="M13 9l-2 10" />
|
||||
</svg>`
|
||||
|
||||
const MERMAID_ICON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="4" width="7" height="5" rx="1.5" />
|
||||
<rect x="14" y="4" width="7" height="5" rx="1.5" />
|
||||
<rect x="8.5" y="15" width="7" height="5" rx="1.5" />
|
||||
<path d="M10 6.5h4" />
|
||||
<path d="M12 9v2.5" />
|
||||
<path d="M12 11.5l-3.5 3.5" />
|
||||
<path d="M12 11.5l3.5 3.5" />
|
||||
</svg>`
|
||||
|
||||
const HTML_ICON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M8 7l-4 5l4 5" />
|
||||
<path d="M16 7l4 5l-4 5" />
|
||||
<path d="M14 4l-4 16" />
|
||||
</svg>`
|
||||
|
||||
const MARKDOWN_ICON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M7 15V9l3 3l3-3v6" />
|
||||
<path d="M16 9v6" />
|
||||
<path d="M14 13l2 2l2-2" />
|
||||
</svg>`
|
||||
|
||||
const JSON_ICON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 4c-2 0-3 1.5-3 3.5v2c0 1.1-.4 1.8-1.5 2.5C5.6 12.7 6 13.4 6 14.5v2C6 18.5 7 20 9 20" />
|
||||
<path d="M15 4c2 0 3 1.5 3 3.5v2c0 1.1.4 1.8 1.5 2.5c-1.1.7-1.5 1.4-1.5 2.5v2c0 2-1 3.5-3 3.5" />
|
||||
</svg>`
|
||||
|
||||
const TEXT_ICON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 6h14" />
|
||||
<path d="M12 6v12" />
|
||||
<path d="M8 18h8" />
|
||||
</svg>`
|
||||
|
||||
const LANGUAGE_ALIAS_MAP: Record<string, string> = {
|
||||
'': '',
|
||||
'cjs': 'javascript',
|
||||
'd2lang': 'd2',
|
||||
'js': 'javascript',
|
||||
'jsx': 'jsx',
|
||||
'md': 'markdown',
|
||||
'mjs': 'javascript',
|
||||
'plaintext': 'plain',
|
||||
'py': 'python',
|
||||
'sh': 'shell',
|
||||
'shellscript': 'shell',
|
||||
'text': 'plain',
|
||||
'ts': 'typescript',
|
||||
'tsx': 'tsx',
|
||||
}
|
||||
|
||||
const LANGUAGE_LABEL_MAP: Record<string, string> = {
|
||||
'': 'Text',
|
||||
'css': 'CSS',
|
||||
'd2': 'D2',
|
||||
'html': 'HTML',
|
||||
'javascript': 'JavaScript',
|
||||
'json': 'JSON',
|
||||
'jsx': 'JSX',
|
||||
'markdown': 'Markdown',
|
||||
'mermaid': 'Mermaid',
|
||||
'plain': 'Text',
|
||||
'plaintext': 'Text',
|
||||
'python': 'Python',
|
||||
'shell': 'Shell',
|
||||
'svg': 'SVG',
|
||||
'tsx': 'TSX',
|
||||
'typescript': 'TypeScript',
|
||||
'vue': 'Vue',
|
||||
}
|
||||
|
||||
const LANGUAGE_ICON_MAP: Record<string, string> = {
|
||||
'': TEXT_ICON,
|
||||
'html': HTML_ICON,
|
||||
'javascript': DEFAULT_ICON,
|
||||
'json': JSON_ICON,
|
||||
'jsx': DEFAULT_ICON,
|
||||
'markdown': MARKDOWN_ICON,
|
||||
'mermaid': MERMAID_ICON,
|
||||
'plain': TEXT_ICON,
|
||||
'python': DEFAULT_ICON,
|
||||
'shell': DEFAULT_ICON,
|
||||
'svg': HTML_ICON,
|
||||
'tsx': DEFAULT_ICON,
|
||||
'typescript': DEFAULT_ICON,
|
||||
}
|
||||
|
||||
let userLanguageIconResolver: LanguageIconResolver | null = null
|
||||
|
||||
function extractLanguageToken(lang?: string | null) {
|
||||
if (!lang)
|
||||
return ''
|
||||
const trimmed = lang.trim()
|
||||
if (!trimmed)
|
||||
return ''
|
||||
const [firstToken] = trimmed.split(/\s+/)
|
||||
const [base] = firstToken.split(':')
|
||||
return base.toLowerCase()
|
||||
}
|
||||
|
||||
export function normalizeLanguageIdentifier(lang?: string | null): string {
|
||||
const token = extractLanguageToken(lang)
|
||||
return LANGUAGE_ALIAS_MAP[token] ?? token
|
||||
}
|
||||
|
||||
export function resolveMonacoLanguageId(lang?: string | null): string {
|
||||
const canonical = normalizeLanguageIdentifier(lang)
|
||||
if (!canonical)
|
||||
return 'plaintext'
|
||||
if (canonical === 'plain')
|
||||
return 'plaintext'
|
||||
if (canonical === 'jsx')
|
||||
return 'javascript'
|
||||
if (canonical === 'tsx')
|
||||
return 'typescript'
|
||||
return canonical
|
||||
}
|
||||
|
||||
export function setLanguageIconResolver(resolver?: LanguageIconResolver | null) {
|
||||
userLanguageIconResolver = resolver ?? null
|
||||
}
|
||||
|
||||
export function getLanguageIcon(lang: string): string {
|
||||
if (userLanguageIconResolver) {
|
||||
const hit = userLanguageIconResolver(lang)
|
||||
if (hit != null && hit !== '')
|
||||
return hit
|
||||
}
|
||||
const normalized = normalizeLanguageIdentifier(lang)
|
||||
return LANGUAGE_ICON_MAP[normalized] || DEFAULT_ICON
|
||||
}
|
||||
|
||||
export const languageMap = LANGUAGE_LABEL_MAP
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export function normalizeKaTeXRenderInput(content: string) {
|
||||
if (!content)
|
||||
return ''
|
||||
|
||||
return content
|
||||
.replace(/·/g, '⋅')
|
||||
.replace(/℃/g, '°C')
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
export type KaTeXCDNWorkerMode = 'classic' | 'module'
|
||||
|
||||
export interface KaTeXCDNWorkerOptions {
|
||||
katexUrl: string
|
||||
mhchemUrl?: string
|
||||
mode?: KaTeXCDNWorkerMode
|
||||
debug?: boolean
|
||||
workerOptions?: WorkerOptions
|
||||
renderOptions?: {
|
||||
throwOnError?: boolean
|
||||
output?: string
|
||||
strict?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface KaTeXCDNWorkerHandle {
|
||||
worker: Worker | null
|
||||
dispose: () => void
|
||||
source: string
|
||||
}
|
||||
|
||||
function stringifyForWorker(value: any) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
export function buildKaTeXCDNWorkerSource(options: KaTeXCDNWorkerOptions): string {
|
||||
const mode: KaTeXCDNWorkerMode = options.mode ?? 'classic'
|
||||
const renderOptions = {
|
||||
throwOnError: true,
|
||||
displayMode: true,
|
||||
output: 'html',
|
||||
strict: 'ignore',
|
||||
...(options.renderOptions || {}),
|
||||
}
|
||||
|
||||
const renderOptionsLiteral = stringifyForWorker(renderOptions)
|
||||
const katexUrlLiteral = stringifyForWorker(options.katexUrl)
|
||||
const mhchemUrlLiteral = options.mhchemUrl ? stringifyForWorker(options.mhchemUrl) : '""'
|
||||
|
||||
if (mode === 'module') {
|
||||
return `
|
||||
let DEBUG = false
|
||||
let katex = null
|
||||
let katexLoadError = null
|
||||
let loadPromise = null
|
||||
|
||||
function normalizeKaTeX(mod) {
|
||||
const resolved = mod && mod.default ? mod.default : mod
|
||||
if (resolved && typeof resolved.renderToString === 'function')
|
||||
return resolved
|
||||
return null
|
||||
}
|
||||
|
||||
async function loadKaTeX() {
|
||||
if (katex || katexLoadError)
|
||||
return
|
||||
if (!loadPromise) {
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const mod = await import(${katexUrlLiteral})
|
||||
katex = normalizeKaTeX(mod) || null
|
||||
const mhchemUrl = ${mhchemUrlLiteral}
|
||||
if (mhchemUrl) {
|
||||
try {
|
||||
await import(mhchemUrl)
|
||||
}
|
||||
catch (e) {
|
||||
if (DEBUG)
|
||||
console.warn('[markstream-angular:katex-cdn-worker] failed to load mhchem', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
katexLoadError = e
|
||||
}
|
||||
})()
|
||||
}
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
globalThis.addEventListener('message', async (ev) => {
|
||||
const data = ev.data || {}
|
||||
if (data.type === 'init') {
|
||||
DEBUG = !!data.debug
|
||||
return
|
||||
}
|
||||
|
||||
const id = data.id ?? ''
|
||||
const content = data.content ?? ''
|
||||
const displayMode = data.displayMode ?? true
|
||||
|
||||
await loadKaTeX()
|
||||
|
||||
if (!katex) {
|
||||
const reason = katexLoadError ? String(katexLoadError?.message || katexLoadError) : 'KaTeX is not available in worker'
|
||||
globalThis.postMessage({ id, error: reason, content, displayMode })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const opts = ${renderOptionsLiteral}
|
||||
const html = katex.renderToString(content, { ...opts, displayMode: !!displayMode })
|
||||
globalThis.postMessage({ id, html, content, displayMode })
|
||||
}
|
||||
catch (err) {
|
||||
globalThis.postMessage({ id, error: String(err?.message || err), content, displayMode })
|
||||
}
|
||||
})
|
||||
`.trimStart()
|
||||
}
|
||||
|
||||
return `
|
||||
let DEBUG = false
|
||||
let katex = null
|
||||
let katexLoadError = null
|
||||
|
||||
function normalizeKaTeX(val) {
|
||||
const resolved = val && val.default ? val.default : val
|
||||
if (resolved && typeof resolved.renderToString === 'function')
|
||||
return resolved
|
||||
return null
|
||||
}
|
||||
|
||||
function loadKaTeXClassic() {
|
||||
if (katex || katexLoadError)
|
||||
return
|
||||
try {
|
||||
importScripts(${katexUrlLiteral})
|
||||
const mhchemUrl = ${mhchemUrlLiteral}
|
||||
if (mhchemUrl) {
|
||||
try {
|
||||
importScripts(mhchemUrl)
|
||||
}
|
||||
catch (e) {
|
||||
if (DEBUG)
|
||||
console.warn('[markstream-angular:katex-cdn-worker] failed to load mhchem', e)
|
||||
}
|
||||
}
|
||||
katex = normalizeKaTeX(globalThis.katex)
|
||||
}
|
||||
catch (e) {
|
||||
katexLoadError = e
|
||||
}
|
||||
}
|
||||
|
||||
loadKaTeXClassic()
|
||||
|
||||
globalThis.addEventListener('message', (ev) => {
|
||||
const data = ev.data || {}
|
||||
if (data.type === 'init') {
|
||||
DEBUG = !!data.debug
|
||||
return
|
||||
}
|
||||
|
||||
const id = data.id ?? ''
|
||||
const content = data.content ?? ''
|
||||
const displayMode = data.displayMode ?? true
|
||||
|
||||
if (!katex && !katexLoadError)
|
||||
loadKaTeXClassic()
|
||||
|
||||
if (!katex) {
|
||||
const reason = katexLoadError ? String(katexLoadError?.message || katexLoadError) : 'KaTeX is not available in worker'
|
||||
globalThis.postMessage({ id, error: reason, content, displayMode })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const opts = ${renderOptionsLiteral}
|
||||
const html = katex.renderToString(content, { ...opts, displayMode: !!displayMode })
|
||||
globalThis.postMessage({ id, html, content, displayMode })
|
||||
}
|
||||
catch (err) {
|
||||
globalThis.postMessage({ id, error: String(err?.message || err), content, displayMode })
|
||||
}
|
||||
})
|
||||
`.trimStart()
|
||||
}
|
||||
|
||||
export function createKaTeXWorkerFromCDN(options: KaTeXCDNWorkerOptions): KaTeXCDNWorkerHandle {
|
||||
const source = buildKaTeXCDNWorkerSource(options)
|
||||
|
||||
if (typeof Worker === 'undefined' || typeof URL === 'undefined' || typeof Blob === 'undefined') {
|
||||
return {
|
||||
worker: null,
|
||||
dispose: () => {},
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob([source], { type: 'text/javascript' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
let revoked = false
|
||||
|
||||
const dispose = () => {
|
||||
if (revoked)
|
||||
return
|
||||
revoked = true
|
||||
try {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
// Ignore revoke failures.
|
||||
}
|
||||
}
|
||||
|
||||
const mode = options.mode ?? 'classic'
|
||||
const workerOptions = mode === 'module'
|
||||
? ({ ...(options.workerOptions ?? {}), type: 'module' as const } satisfies WorkerOptions)
|
||||
: options.workerOptions
|
||||
|
||||
const worker = new Worker(url, workerOptions)
|
||||
if (options.debug) {
|
||||
try {
|
||||
worker.postMessage({ type: 'init', debug: true })
|
||||
}
|
||||
catch {
|
||||
// Ignore init messaging failures.
|
||||
}
|
||||
}
|
||||
|
||||
return { worker, dispose, source }
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import katex from 'katex'
|
||||
import 'katex/dist/contrib/mhchem'
|
||||
|
||||
interface MessageIn {
|
||||
type?: 'init' | 'render'
|
||||
id?: string
|
||||
content?: string
|
||||
displayMode?: boolean
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
interface MessageOut {
|
||||
id: string
|
||||
html?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
let debugWorker = false
|
||||
|
||||
;(globalThis as any).addEventListener('message', (event: MessageEvent<MessageIn>) => {
|
||||
const data = event.data || {}
|
||||
if (data.type === 'init') {
|
||||
debugWorker = !!data.debug
|
||||
return
|
||||
}
|
||||
|
||||
const id = data.id ?? ''
|
||||
const content = data.content ?? ''
|
||||
const displayMode = data.displayMode ?? true
|
||||
|
||||
try {
|
||||
if (debugWorker)
|
||||
console.debug('[markstream-angular:katexRenderer.worker] render start', { id, displayMode, content })
|
||||
|
||||
const html = katex.renderToString(content, {
|
||||
throwOnError: true,
|
||||
displayMode,
|
||||
output: 'html',
|
||||
strict: 'ignore',
|
||||
})
|
||||
|
||||
const result: MessageOut & { content: string, displayMode: boolean } = {
|
||||
id,
|
||||
html,
|
||||
content,
|
||||
displayMode,
|
||||
}
|
||||
;(globalThis as any).postMessage(result)
|
||||
}
|
||||
catch (error: any) {
|
||||
const result: MessageOut & { content: string, displayMode: boolean } = {
|
||||
id,
|
||||
error: String(error?.message ?? error),
|
||||
content,
|
||||
displayMode,
|
||||
}
|
||||
;(globalThis as any).postMessage(result)
|
||||
}
|
||||
})
|
||||
|
||||
;(globalThis as any).addEventListener('error', (event: ErrorEvent) => {
|
||||
try {
|
||||
;(globalThis as any).postMessage({
|
||||
id: '__worker_uncaught__',
|
||||
error: String(event.message ?? event.error),
|
||||
content: '',
|
||||
displayMode: true,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// Ignore postMessage failures while surfacing uncaught worker errors.
|
||||
}
|
||||
})
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
import { isKatexEnabled } from '../optional/katex'
|
||||
import { normalizeKaTeXRenderInput } from '../utils/normalizeKaTeXRenderInput'
|
||||
|
||||
export const WORKER_BUSY_CODE = 'WORKER_BUSY'
|
||||
|
||||
interface Pending {
|
||||
resolve: (val: string) => void
|
||||
reject: (err: any) => void
|
||||
timeoutId: number
|
||||
}
|
||||
|
||||
let worker: Worker | null = null
|
||||
let workerInitError: any = null
|
||||
let debugKatexWorker = false
|
||||
|
||||
const pending = new Map<string, Pending>()
|
||||
const cache = new Map<string, string>()
|
||||
const cacheMax = 200
|
||||
const drainWaiters = new Set<() => void>()
|
||||
|
||||
let maxConcurrency = 5
|
||||
|
||||
function notifyDrainIfBelowCap() {
|
||||
if (pending.size >= maxConcurrency || drainWaiters.size === 0)
|
||||
return
|
||||
|
||||
const currentWaiters = Array.from(drainWaiters)
|
||||
drainWaiters.clear()
|
||||
for (const waiter of currentWaiters) {
|
||||
try {
|
||||
waiter()
|
||||
}
|
||||
catch {
|
||||
// Ignore waiter failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rememberCache(content: string, displayMode: boolean, html: string) {
|
||||
cache.set(`${displayMode ? 'd' : 'i'}:${content}`, html)
|
||||
if (cache.size > cacheMax) {
|
||||
const firstKey = cache.keys().next().value
|
||||
if (firstKey)
|
||||
cache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function setKaTeXWorker(nextWorker: Worker) {
|
||||
worker = nextWorker
|
||||
workerInitError = null
|
||||
|
||||
worker.onmessage = (event: MessageEvent) => {
|
||||
const { id, html, error, content, displayMode } = event.data || {}
|
||||
const active = pending.get(id)
|
||||
if (!active)
|
||||
return
|
||||
|
||||
pending.delete(id)
|
||||
clearTimeout(active.timeoutId)
|
||||
notifyDrainIfBelowCap()
|
||||
|
||||
if (error) {
|
||||
active.reject(new Error(error))
|
||||
return
|
||||
}
|
||||
|
||||
if (content)
|
||||
rememberCache(String(content), Boolean(displayMode), String(html || ''))
|
||||
active.resolve(String(html || ''))
|
||||
}
|
||||
|
||||
worker.onerror = (event: ErrorEvent) => {
|
||||
for (const [, active] of pending.entries()) {
|
||||
clearTimeout(active.timeoutId)
|
||||
active.reject(new Error(`Worker error: ${event.message}`))
|
||||
}
|
||||
pending.clear()
|
||||
notifyDrainIfBelowCap()
|
||||
}
|
||||
|
||||
if (debugKatexWorker)
|
||||
worker.postMessage({ type: 'init', debug: true })
|
||||
}
|
||||
|
||||
export function clearKaTeXWorker() {
|
||||
if (worker)
|
||||
worker.terminate?.()
|
||||
worker = null
|
||||
workerInitError = null
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (worker)
|
||||
return worker
|
||||
|
||||
workerInitError = new Error('[markstream-angular:katexWorkerClient] No worker instance set. Please inject a Worker via setKaTeXWorker().')
|
||||
;(workerInitError as any).name = 'WorkerInitError'
|
||||
;(workerInitError as any).code = 'WORKER_INIT_ERROR'
|
||||
return null
|
||||
}
|
||||
|
||||
export function setKaTeXWorkerDebug(enabled: boolean) {
|
||||
debugKatexWorker = !!enabled
|
||||
if (worker)
|
||||
worker.postMessage({ type: 'init', debug: debugKatexWorker })
|
||||
}
|
||||
|
||||
export async function renderKaTeXInWorker(content: string, displayMode = true, timeout = 2000, signal?: AbortSignal): Promise<string> {
|
||||
const normalizedContent = normalizeKaTeXRenderInput(content)
|
||||
if (!isKatexEnabled()) {
|
||||
const error = new Error('KaTeX rendering disabled')
|
||||
;(error as any).name = 'KaTeXDisabled'
|
||||
;(error as any).code = 'KATEX_DISABLED'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (workerInitError)
|
||||
return Promise.reject(workerInitError)
|
||||
|
||||
const cacheKey = `${displayMode ? 'd' : 'i'}:${normalizedContent}`
|
||||
const cached = cache.get(cacheKey)
|
||||
if (cached)
|
||||
return cached
|
||||
|
||||
const activeWorker = ensureWorker()
|
||||
if (!activeWorker)
|
||||
return Promise.reject(workerInitError)
|
||||
|
||||
if (pending.size >= maxConcurrency) {
|
||||
const error = new Error('Worker busy')
|
||||
;(error as any).name = 'WorkerBusy'
|
||||
;(error as any).code = WORKER_BUSY_CODE
|
||||
;(error as any).busy = true
|
||||
;(error as any).inFlight = pending.size
|
||||
;(error as any).max = maxConcurrency
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
const error = new Error('Aborted')
|
||||
;(error as any).name = 'AbortError'
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
const id = Math.random().toString(36).slice(2)
|
||||
const timeoutId = globalThis.setTimeout(() => {
|
||||
pending.delete(id)
|
||||
notifyDrainIfBelowCap()
|
||||
const error = new Error('Worker render timed out')
|
||||
;(error as any).name = 'WorkerTimeout'
|
||||
;(error as any).code = 'WORKER_TIMEOUT'
|
||||
reject(error)
|
||||
}, timeout)
|
||||
|
||||
const onAbort = () => {
|
||||
globalThis.clearTimeout(timeoutId)
|
||||
if (pending.delete(id))
|
||||
notifyDrainIfBelowCap()
|
||||
const error = new Error('Aborted')
|
||||
;(error as any).name = 'AbortError'
|
||||
reject(error)
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
pending.set(id, {
|
||||
timeoutId,
|
||||
resolve: (html) => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve(html)
|
||||
},
|
||||
reject: (error) => {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
reject(error)
|
||||
},
|
||||
})
|
||||
|
||||
activeWorker.postMessage({
|
||||
id,
|
||||
type: 'render',
|
||||
content: normalizedContent,
|
||||
displayMode,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function setKaTeXCache(content: string, displayMode = true, html: string) {
|
||||
rememberCache(normalizeKaTeXRenderInput(content), displayMode, html)
|
||||
}
|
||||
|
||||
export function getKaTeXWorkerLoad() {
|
||||
return {
|
||||
inFlight: pending.size,
|
||||
max: maxConcurrency,
|
||||
}
|
||||
}
|
||||
|
||||
export function setKaTeXWorkerMaxConcurrency(value: number) {
|
||||
if (Number.isFinite(value) && value > 0)
|
||||
maxConcurrency = Math.floor(value)
|
||||
}
|
||||
|
||||
export function isKaTeXWorkerBusy() {
|
||||
return pending.size >= maxConcurrency
|
||||
}
|
||||
|
||||
export function waitForKaTeXWorkerSlot(timeout = 2000, signal?: AbortSignal): Promise<void> {
|
||||
if (pending.size < maxConcurrency)
|
||||
return Promise.resolve()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const onDrain = () => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
if (timer)
|
||||
clearTimeout(timer)
|
||||
drainWaiters.delete(onDrain)
|
||||
resolve()
|
||||
}
|
||||
|
||||
drainWaiters.add(onDrain)
|
||||
|
||||
timer = setTimeout(() => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
drainWaiters.delete(onDrain)
|
||||
const error = new Error('Wait for worker slot timed out')
|
||||
;(error as any).name = 'WorkerBusyTimeout'
|
||||
;(error as any).code = 'WORKER_BUSY_TIMEOUT'
|
||||
reject(error)
|
||||
}, timeout)
|
||||
|
||||
queueMicrotask(() => notifyDrainIfBelowCap())
|
||||
|
||||
if (signal) {
|
||||
const onAbort = () => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
if (timer)
|
||||
clearTimeout(timer)
|
||||
drainWaiters.delete(onDrain)
|
||||
const error = new Error('Aborted')
|
||||
;(error as any).name = 'AbortError'
|
||||
reject(error)
|
||||
}
|
||||
if (signal.aborted)
|
||||
onAbort()
|
||||
else
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export interface BackpressureOptions {
|
||||
timeout?: number
|
||||
waitTimeout?: number
|
||||
backoffMs?: number
|
||||
maxRetries?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
const defaultBackpressure = {
|
||||
timeout: 2000,
|
||||
waitTimeout: 1500,
|
||||
backoffMs: 30,
|
||||
maxRetries: 1,
|
||||
}
|
||||
const maxBackpressureRetries = 8
|
||||
|
||||
export function setKaTeXBackpressureDefaults(options: Partial<typeof defaultBackpressure>) {
|
||||
if (options.timeout != null)
|
||||
defaultBackpressure.timeout = Math.max(0, Math.floor(options.timeout))
|
||||
if (options.waitTimeout != null)
|
||||
defaultBackpressure.waitTimeout = Math.max(0, Math.floor(options.waitTimeout))
|
||||
if (options.backoffMs != null)
|
||||
defaultBackpressure.backoffMs = Math.max(0, Math.floor(options.backoffMs))
|
||||
if (options.maxRetries != null)
|
||||
defaultBackpressure.maxRetries = Math.max(0, Math.floor(options.maxRetries))
|
||||
}
|
||||
|
||||
export function getKaTeXBackpressureDefaults() {
|
||||
return { ...defaultBackpressure }
|
||||
}
|
||||
|
||||
export async function renderKaTeXWithBackpressure(
|
||||
content: string,
|
||||
displayMode = true,
|
||||
options: BackpressureOptions = {},
|
||||
): Promise<string> {
|
||||
if (!isKatexEnabled()) {
|
||||
const error = new Error('KaTeX rendering disabled')
|
||||
;(error as any).name = 'KaTeXDisabled'
|
||||
;(error as any).code = 'KATEX_DISABLED'
|
||||
throw error
|
||||
}
|
||||
|
||||
const timeout = options.timeout ?? defaultBackpressure.timeout
|
||||
const waitTimeout = options.waitTimeout ?? defaultBackpressure.waitTimeout
|
||||
const backoffMs = options.backoffMs ?? defaultBackpressure.backoffMs
|
||||
const rawMaxRetries = options.maxRetries ?? defaultBackpressure.maxRetries
|
||||
const maxRetries = Number.isFinite(rawMaxRetries)
|
||||
? Math.max(0, Math.min(Math.floor(rawMaxRetries), maxBackpressureRetries))
|
||||
: defaultBackpressure.maxRetries
|
||||
|
||||
let attempt = 0
|
||||
for (;;) {
|
||||
if (options.signal?.aborted) {
|
||||
const error = new Error('Aborted')
|
||||
;(error as any).name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
return await renderKaTeXInWorker(content, displayMode, timeout, options.signal)
|
||||
}
|
||||
catch (error: any) {
|
||||
if (error?.code !== WORKER_BUSY_CODE || attempt >= maxRetries)
|
||||
throw error
|
||||
|
||||
attempt += 1
|
||||
await waitForKaTeXWorkerSlot(waitTimeout, options.signal).catch(() => {})
|
||||
if (backoffMs > 0)
|
||||
await new Promise(resolve => globalThis.setTimeout(resolve, backoffMs * attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
export type MermaidCDNWorkerMode = 'classic' | 'module'
|
||||
|
||||
export interface MermaidCDNWorkerOptions {
|
||||
mermaidUrl: string
|
||||
mode?: MermaidCDNWorkerMode
|
||||
debug?: boolean
|
||||
workerOptions?: WorkerOptions
|
||||
initializeOptions?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface MermaidCDNWorkerHandle {
|
||||
worker: Worker | null
|
||||
dispose: () => void
|
||||
source: string
|
||||
}
|
||||
|
||||
function stringifyForWorker(value: any) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
export function buildMermaidCDNWorkerSource(options: MermaidCDNWorkerOptions): string {
|
||||
const mode: MermaidCDNWorkerMode = options.mode ?? 'module'
|
||||
const mermaidUrlLiteral = stringifyForWorker(options.mermaidUrl)
|
||||
const initLiteral = stringifyForWorker({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'loose',
|
||||
...(options.initializeOptions || {}),
|
||||
})
|
||||
|
||||
const sharedLogic = `
|
||||
let DEBUG = false
|
||||
let mermaid = null
|
||||
let mermaidLoadError = null
|
||||
|
||||
function normalizeMermaidModule(mod) {
|
||||
if (!mod)
|
||||
return mod
|
||||
const candidate = mod && mod.default ? mod.default : mod
|
||||
if (candidate && (typeof candidate.render === 'function' || typeof candidate.parse === 'function' || typeof candidate.initialize === 'function'))
|
||||
return candidate
|
||||
if (candidate && candidate.mermaidAPI && (typeof candidate.mermaidAPI.render === 'function' || typeof candidate.mermaidAPI.parse === 'function')) {
|
||||
const api = candidate.mermaidAPI
|
||||
return {
|
||||
...candidate,
|
||||
render: api.render ? api.render.bind(api) : undefined,
|
||||
parse: api.parse ? api.parse.bind(api) : undefined,
|
||||
initialize: (opts) => {
|
||||
if (typeof candidate.initialize === 'function')
|
||||
return candidate.initialize(opts)
|
||||
return api.initialize ? api.initialize(opts) : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
if (mod && mod.mermaid && typeof mod.mermaid.parse === 'function')
|
||||
return mod.mermaid
|
||||
return candidate
|
||||
}
|
||||
|
||||
function applyThemeTo(code, theme) {
|
||||
const themeValue = theme === 'dark' ? 'dark' : 'default'
|
||||
const themeConfig = \`%%{init: {"theme": "\${themeValue}"}}%%\\n\`
|
||||
const trimmed = String(code || '').trimStart()
|
||||
if (trimmed.startsWith('%%{'))
|
||||
return code
|
||||
return themeConfig + code
|
||||
}
|
||||
|
||||
function findHeaderIndex(lines) {
|
||||
const headerRe = /^(?:graph|flowchart|flowchart\\s+tb|flowchart\\s+lr|sequenceDiagram|gantt|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|pie|quadrantChart|timeline|xychart(?:-beta)?)\\b/
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = (lines[i] || '').trim()
|
||||
if (!line || line.startsWith('%%'))
|
||||
continue
|
||||
if (headerRe.test(line))
|
||||
return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
async function canParse(code, theme) {
|
||||
const themed = applyThemeTo(code, theme)
|
||||
if (mermaid && typeof mermaid.parse === 'function') {
|
||||
await mermaid.parse(themed)
|
||||
return true
|
||||
}
|
||||
throw new Error('mermaid.parse not available in worker')
|
||||
}
|
||||
|
||||
async function findLastRenderablePrefix(baseCode, theme) {
|
||||
const lines = String(baseCode || '').split('\\n')
|
||||
const headerIndex = findHeaderIndex(lines)
|
||||
if (headerIndex === -1)
|
||||
return null
|
||||
const head = lines.slice(0, headerIndex + 1)
|
||||
await canParse(head.join('\\n'), theme)
|
||||
|
||||
let low = headerIndex + 1
|
||||
let high = lines.length
|
||||
let lastGood = headerIndex + 1
|
||||
let tries = 0
|
||||
const MAX_TRIES = 12
|
||||
|
||||
while (low <= high && tries < MAX_TRIES) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
const candidate = [...head, ...lines.slice(headerIndex + 1, mid)].join('\\n')
|
||||
tries += 1
|
||||
try {
|
||||
await canParse(candidate, theme)
|
||||
lastGood = mid
|
||||
low = mid + 1
|
||||
}
|
||||
catch {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return [...head, ...lines.slice(headerIndex + 1, lastGood)].join('\\n')
|
||||
}
|
||||
|
||||
function initMermaidOnce() {
|
||||
if (!mermaid)
|
||||
return
|
||||
try {
|
||||
if (typeof mermaid.initialize === 'function')
|
||||
mermaid.initialize(${initLiteral})
|
||||
}
|
||||
catch (e) {
|
||||
if (DEBUG)
|
||||
console.warn('[markstream-angular:mermaid-cdn-worker] initialize failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.addEventListener('message', async (ev) => {
|
||||
const msg = ev.data || {}
|
||||
if (msg.type === 'init') {
|
||||
DEBUG = !!msg.debug
|
||||
return
|
||||
}
|
||||
|
||||
const id = msg.id
|
||||
const action = msg.action
|
||||
const payload = msg.payload || {}
|
||||
|
||||
if (!mermaid) {
|
||||
const error = mermaidLoadError ? String(mermaidLoadError?.message || mermaidLoadError) : 'Mermaid is not available in worker'
|
||||
globalThis.postMessage({ id, ok: false, error })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'canParse') {
|
||||
const ok = await canParse(payload.code, payload.theme)
|
||||
globalThis.postMessage({ id, ok: true, result: ok })
|
||||
return
|
||||
}
|
||||
if (action === 'findPrefix') {
|
||||
const result = await findLastRenderablePrefix(payload.code, payload.theme)
|
||||
globalThis.postMessage({ id, ok: true, result })
|
||||
return
|
||||
}
|
||||
globalThis.postMessage({ id, ok: false, error: 'Unknown action' })
|
||||
}
|
||||
catch (e) {
|
||||
globalThis.postMessage({ id, ok: false, error: String(e?.message || e) })
|
||||
}
|
||||
})
|
||||
`.trimStart()
|
||||
|
||||
if (mode === 'module') {
|
||||
return `
|
||||
${sharedLogic}
|
||||
|
||||
let loadPromise = null
|
||||
async function loadMermaid() {
|
||||
if (mermaid || mermaidLoadError)
|
||||
return
|
||||
if (!loadPromise) {
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const mod = await import(${mermaidUrlLiteral})
|
||||
mermaid = normalizeMermaidModule(mod) || null
|
||||
initMermaidOnce()
|
||||
}
|
||||
catch (e) {
|
||||
mermaidLoadError = e
|
||||
}
|
||||
})()
|
||||
}
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
await loadMermaid()
|
||||
`.trimStart()
|
||||
}
|
||||
|
||||
return `
|
||||
${sharedLogic}
|
||||
|
||||
function loadMermaidClassic() {
|
||||
if (mermaid || mermaidLoadError)
|
||||
return
|
||||
try {
|
||||
importScripts(${mermaidUrlLiteral})
|
||||
mermaid = normalizeMermaidModule(globalThis.mermaid) || null
|
||||
initMermaidOnce()
|
||||
}
|
||||
catch (e) {
|
||||
mermaidLoadError = e
|
||||
}
|
||||
}
|
||||
|
||||
loadMermaidClassic()
|
||||
`.trimStart()
|
||||
}
|
||||
|
||||
export function createMermaidWorkerFromCDN(options: MermaidCDNWorkerOptions): MermaidCDNWorkerHandle {
|
||||
const source = buildMermaidCDNWorkerSource(options)
|
||||
|
||||
if (typeof Worker === 'undefined' || typeof URL === 'undefined' || typeof Blob === 'undefined') {
|
||||
return {
|
||||
worker: null,
|
||||
dispose: () => {},
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob([source], { type: 'text/javascript' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
let revoked = false
|
||||
|
||||
const dispose = () => {
|
||||
if (revoked)
|
||||
return
|
||||
revoked = true
|
||||
try {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
// Ignore revoke failures.
|
||||
}
|
||||
}
|
||||
|
||||
const mode = options.mode ?? 'module'
|
||||
const workerOptions = mode === 'module'
|
||||
? ({ ...(options.workerOptions ?? {}), type: 'module' as const } satisfies WorkerOptions)
|
||||
: options.workerOptions
|
||||
|
||||
const worker = new Worker(url, workerOptions)
|
||||
if (options.debug) {
|
||||
try {
|
||||
worker.postMessage({ type: 'init', debug: true })
|
||||
}
|
||||
catch {
|
||||
// Ignore init messaging failures.
|
||||
}
|
||||
}
|
||||
|
||||
return { worker, dispose, source }
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
declare const self: DedicatedWorkerGlobalScope
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' })
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
interface RequestMessage {
|
||||
id: string
|
||||
action: 'canParse' | 'findPrefix' | (string & {})
|
||||
payload: { code: string, theme: Theme }
|
||||
}
|
||||
|
||||
type ResponseMessage
|
||||
= | { id: string, ok: true, result: any }
|
||||
| { id: string, ok: false, error: string }
|
||||
|
||||
function applyThemeTo(code: string, theme: Theme) {
|
||||
const themeValue = theme === 'dark' ? 'dark' : 'default'
|
||||
const themeConfig = `%%{init: {"theme": "${themeValue}"}}%%\n`
|
||||
const trimmed = code.trimStart()
|
||||
if (trimmed.startsWith('%%{'))
|
||||
return code
|
||||
return themeConfig + code
|
||||
}
|
||||
|
||||
function findHeaderIndex(lines: string[]) {
|
||||
const headerRe = /^(?:graph|flowchart|flowchart\s+tb|flowchart\s+lr|sequenceDiagram|gantt|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|pie|quadrantChart|timeline|xychart(?:-beta)?)\b/
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index].trim()
|
||||
if (!line || line.startsWith('%%'))
|
||||
continue
|
||||
if (headerRe.test(line))
|
||||
return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
async function canParse(code: string, theme: Theme) {
|
||||
const themed = applyThemeTo(code, theme)
|
||||
const anyMermaid = mermaid as any
|
||||
if (typeof anyMermaid.parse === 'function') {
|
||||
await anyMermaid.parse(themed)
|
||||
return true
|
||||
}
|
||||
throw new Error('mermaid.parse not available in worker')
|
||||
}
|
||||
|
||||
async function findLastRenderablePrefix(baseCode: string, theme: Theme) {
|
||||
const lines = baseCode.split('\n')
|
||||
const headerIndex = findHeaderIndex(lines)
|
||||
if (headerIndex === -1)
|
||||
return null
|
||||
|
||||
const head = lines.slice(0, headerIndex + 1)
|
||||
await canParse(head.join('\n'), theme)
|
||||
|
||||
let low = headerIndex + 1
|
||||
let high = lines.length
|
||||
let lastGood = headerIndex + 1
|
||||
let tries = 0
|
||||
const maxTries = 12
|
||||
|
||||
while (low <= high && tries < maxTries) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
const candidate = [...head, ...lines.slice(headerIndex + 1, mid)].join('\n')
|
||||
tries += 1
|
||||
try {
|
||||
await canParse(candidate, theme)
|
||||
lastGood = mid
|
||||
low = mid + 1
|
||||
}
|
||||
catch {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return [...head, ...lines.slice(headerIndex + 1, lastGood)].join('\n')
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<RequestMessage>) => {
|
||||
const message = event.data
|
||||
const send = (response: ResponseMessage) => self.postMessage(response)
|
||||
|
||||
try {
|
||||
if (message.action === 'canParse') {
|
||||
send({
|
||||
id: message.id,
|
||||
ok: true,
|
||||
result: await canParse(message.payload.code, message.payload.theme),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (message.action === 'findPrefix') {
|
||||
send({
|
||||
id: message.id,
|
||||
ok: true,
|
||||
result: await findLastRenderablePrefix(message.payload.code, message.payload.theme),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
send({ id: message.id, ok: false, error: 'Unknown action' })
|
||||
}
|
||||
catch (error: any) {
|
||||
send({ id: message.id, ok: false, error: error?.message ?? String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
import { isMermaidEnabled } from '../optional/mermaid'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
let worker: Worker | null = null
|
||||
let workerInitError: any = null
|
||||
|
||||
interface Pending {
|
||||
resolve: (value: any) => void
|
||||
reject: (error: any) => void
|
||||
}
|
||||
|
||||
const rpcMap = new Map<string, Pending>()
|
||||
let maxConcurrency = 5
|
||||
let debugClient = false
|
||||
|
||||
export function setMermaidWorkerClientDebug(enabled: boolean) {
|
||||
debugClient = !!enabled
|
||||
}
|
||||
|
||||
export function setMermaidWorkerMaxConcurrency(value: number) {
|
||||
if (Number.isFinite(value) && value > 0)
|
||||
maxConcurrency = Math.floor(value)
|
||||
}
|
||||
|
||||
export function getMermaidWorkerLoad() {
|
||||
return { inFlight: rpcMap.size, max: maxConcurrency }
|
||||
}
|
||||
|
||||
export const MERMAID_WORKER_BUSY_CODE = 'WORKER_BUSY'
|
||||
export const MERMAID_DISABLED_CODE = 'MERMAID_DISABLED'
|
||||
|
||||
export function setMermaidWorker(nextWorker: Worker) {
|
||||
worker = nextWorker
|
||||
workerInitError = null
|
||||
const current = nextWorker
|
||||
|
||||
worker.onmessage = (event: MessageEvent) => {
|
||||
if (worker !== current)
|
||||
return
|
||||
|
||||
const { id, ok, result, error } = event.data || {}
|
||||
const active = rpcMap.get(id)
|
||||
if (!active)
|
||||
return
|
||||
|
||||
if (ok === false || error)
|
||||
active.reject(new Error(error || 'Unknown error'))
|
||||
else
|
||||
active.resolve(result)
|
||||
}
|
||||
|
||||
worker.onerror = (event: ErrorEvent) => {
|
||||
if (worker !== current)
|
||||
return
|
||||
if (rpcMap.size === 0) {
|
||||
console.debug?.('[markstream-angular:mermaidWorkerClient] Worker error (idle):', event?.message || event)
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (debugClient)
|
||||
console.error('[markstream-angular:mermaidWorkerClient] Worker error:', event?.message || event)
|
||||
else
|
||||
console.debug?.('[markstream-angular:mermaidWorkerClient] Worker error:', event?.message || event)
|
||||
}
|
||||
catch {
|
||||
// Ignore logging failures.
|
||||
}
|
||||
for (const [, active] of rpcMap.entries())
|
||||
active.reject(new Error(`Worker error: ${event.message}`))
|
||||
rpcMap.clear()
|
||||
}
|
||||
|
||||
;(worker as any).onmessageerror = (event: MessageEvent) => {
|
||||
if (worker !== current)
|
||||
return
|
||||
if (rpcMap.size === 0) {
|
||||
console.debug?.('[markstream-angular:mermaidWorkerClient] Worker messageerror (idle):', event)
|
||||
return
|
||||
}
|
||||
for (const [, active] of rpcMap.entries())
|
||||
active.reject(new Error('Worker messageerror'))
|
||||
rpcMap.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMermaidWorker() {
|
||||
if (worker) {
|
||||
try {
|
||||
for (const [, active] of rpcMap.entries())
|
||||
active.reject(new Error('Worker cleared'))
|
||||
rpcMap.clear()
|
||||
worker.terminate?.()
|
||||
}
|
||||
catch {
|
||||
// Ignore worker termination failures.
|
||||
}
|
||||
}
|
||||
worker = null
|
||||
workerInitError = null
|
||||
}
|
||||
|
||||
function ensureWorker() {
|
||||
if (worker)
|
||||
return worker
|
||||
|
||||
workerInitError = new Error('[markstream-angular:mermaidWorkerClient] No worker instance set. Please inject a Worker via setMermaidWorker().')
|
||||
;(workerInitError as any).name = 'WorkerInitError'
|
||||
;(workerInitError as any).code = 'WORKER_INIT_ERROR'
|
||||
return null
|
||||
}
|
||||
|
||||
function callWorker<T>(action: 'canParse' | 'findPrefix', payload: any, timeout = 1400): Promise<T> {
|
||||
if (!isMermaidEnabled()) {
|
||||
const error: any = new Error('Mermaid rendering disabled')
|
||||
error.name = 'MermaidDisabled'
|
||||
error.code = MERMAID_DISABLED_CODE
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (workerInitError)
|
||||
return Promise.reject(workerInitError)
|
||||
|
||||
const activeWorker = ensureWorker()
|
||||
if (!activeWorker)
|
||||
return Promise.reject(workerInitError)
|
||||
|
||||
if (rpcMap.size >= maxConcurrency) {
|
||||
const error: any = new Error('Worker busy')
|
||||
error.name = 'WorkerBusy'
|
||||
error.code = MERMAID_WORKER_BUSY_CODE
|
||||
error.inFlight = rpcMap.size
|
||||
error.max = maxConcurrency
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const id = Math.random().toString(36).slice(2)
|
||||
let settled = false
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
if (timeoutId != null)
|
||||
clearTimeout(timeoutId)
|
||||
rpcMap.delete(id)
|
||||
}
|
||||
|
||||
rpcMap.set(id, {
|
||||
resolve: (value: any) => {
|
||||
cleanup()
|
||||
resolve(value)
|
||||
},
|
||||
reject: (error: any) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
activeWorker.postMessage({ id, action, payload })
|
||||
}
|
||||
catch (error) {
|
||||
rpcMap.delete(id)
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
const error: any = new Error('Worker call timed out')
|
||||
error.name = 'WorkerTimeout'
|
||||
error.code = 'WORKER_TIMEOUT'
|
||||
const pending = rpcMap.get(id)
|
||||
pending?.reject(error)
|
||||
}, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
export async function canParseOffthread(code: string, theme: Theme, timeout = 1400) {
|
||||
return await callWorker<boolean>('canParse', { code, theme }, timeout)
|
||||
}
|
||||
|
||||
export async function findPrefixOffthread(code: string, theme: Theme, timeout = 1400) {
|
||||
return await callWorker<string | null>('findPrefix', { code, theme }, timeout)
|
||||
}
|
||||
|
||||
export function terminateWorker() {
|
||||
if (worker) {
|
||||
try {
|
||||
for (const [, active] of rpcMap.entries())
|
||||
active.reject(new Error('Worker terminated'))
|
||||
rpcMap.clear()
|
||||
worker.terminate()
|
||||
}
|
||||
finally {
|
||||
worker = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": false
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["DOM", "ES2022"],
|
||||
"useDefineForClassFields": false,
|
||||
"experimentalDecorators": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["vite/client"],
|
||||
"strict": false,
|
||||
"declaration": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import path from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import dts from 'vite-plugin-dts'
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: mode === 'npm' ? '' : './',
|
||||
plugins: [
|
||||
dts({
|
||||
insertTypesEntry: true,
|
||||
outDir: 'dist/types',
|
||||
tsconfigPath: './tsconfig.build.json',
|
||||
}),
|
||||
],
|
||||
resolve: mode === 'npm'
|
||||
? undefined
|
||||
: {
|
||||
alias: [
|
||||
{
|
||||
find: /^stream-markdown-parser$/,
|
||||
replacement: path.resolve(__dirname, '../markdown-parser/src/index.ts'),
|
||||
},
|
||||
{
|
||||
find: /^stream-markdown-parser\//,
|
||||
replacement: `${path.resolve(__dirname, '../markdown-parser/src')}/`,
|
||||
},
|
||||
],
|
||||
},
|
||||
build: {
|
||||
copyPublicDir: false,
|
||||
lib: {
|
||||
entry: './src/index.ts',
|
||||
formats: ['es'],
|
||||
name: 'markstream-angular',
|
||||
fileName: () => 'index.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'@antv/infographic',
|
||||
'@angular/common',
|
||||
'@angular/core',
|
||||
'@terrastruct/d2',
|
||||
'katex',
|
||||
'katex/contrib/mhchem',
|
||||
'katex/dist/contrib/mhchem',
|
||||
'mermaid',
|
||||
'stream-monaco',
|
||||
'stream-markdown-parser',
|
||||
],
|
||||
output: {
|
||||
assetFileNames(assetInfo) {
|
||||
if ((assetInfo.name || '').endsWith('.css'))
|
||||
return 'index.css'
|
||||
return '[name][extname]'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
format: 'es',
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'katex',
|
||||
'katex/contrib/mhchem',
|
||||
'katex/dist/contrib/mhchem',
|
||||
'mermaid',
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'workers/[name].js',
|
||||
chunkFileNames: 'workers/[name].js',
|
||||
assetFileNames: 'workers/[name][extname]',
|
||||
},
|
||||
},
|
||||
},
|
||||
css: {
|
||||
postcss: path.resolve(__dirname, './postcss.config.cjs'),
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user