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

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

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

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,824 @@
import type { AfterViewInit, ElementRef, OnChanges, OnDestroy } from '@angular/core'
import type { AngularRenderableNode, AngularRenderContext } from '../shared/node-helpers'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
ViewChild,
} from '@angular/core'
import { useSafeI18n } from '../../i18n/useSafeI18n'
import { getUseMonaco } from '../../optional/monaco'
import { getLanguageIcon, languageMap, normalizeLanguageIdentifier, resolveMonacoLanguageId } from '../../utils/languageIcon'
import { PreCodeNodeComponent } from '../PreCodeNode/PreCodeNode.component'
import { getString } from '../shared/node-helpers'
import { HtmlPreviewFrameComponent } from './HtmlPreviewFrame.component'
interface MonacoHelpers {
createEditor?: (container: HTMLElement, code: string, language: string) => Promise<unknown> | unknown
createDiffEditor?: (container: HTMLElement, original: string, modified: string, language: string) => Promise<unknown> | unknown
updateCode?: (code: string, language?: string) => Promise<unknown> | unknown
updateDiff?: (original: string, modified: string, language?: string) => Promise<unknown> | unknown
cleanupEditor?: () => void
safeClean?: () => void
getEditorView?: () => any
getDiffEditorView?: () => any
setTheme?: (theme?: string) => Promise<unknown> | unknown
}
@Component({
selector: 'markstream-angular-code-block-node',
standalone: true,
imports: [CommonModule, PreCodeNodeComponent, HtmlPreviewFrameComponent],
template: `
<div
class="code-block-container"
[class.is-dark]="resolvedIsDark"
[class.is-plain-text]="isPlainTextLanguage"
[class.is-rendering]="resolvedLoading"
[attr.data-markstream-monaco]="editorReady && !useFallback ? '1' : null"
[attr.data-markstream-monaco-diff]="editorReady && isDiff && !useFallback ? '1' : null"
[ngStyle]="containerStyle"
>
<div
*ngIf="resolvedShowHeader"
class="code-block-header"
[style.color]="headerForeground"
[style.backgroundColor]="headerBackground"
>
<div class="code-block-header__meta">
<span class="icon-slot code-block-language-icon">
<img class="code-block-language-icon__image" [src]="languageIconDataUrl" alt="" />
</span>
<span class="code-block-header__label">{{ displayLanguage }}</span>
</div>
<div class="code-block-header__actions">
<button
*ngIf="resolvedShowCollapseButton"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(collapsed ? t('common.expand') : t('common.collapse'))"
[attr.aria-pressed]="collapsed"
(click)="toggleCollapsed()"
>
<svg
[style.rotate]="collapsed ? '0deg' : '90deg'"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="code-action-btn__icon"
>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 18l6-6l-6-6" />
</svg>
</button>
<ng-container *ngIf="resolvedShowFontSizeButtons && resolvedEnableFontSizeControl">
<button
type="button"
class="code-action-btn"
[disabled]="fontSize <= 10"
[attr.title]="tooltipFor(t('common.decrease'))"
(click)="changeFontSize(-1)"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14" />
</svg>
</button>
<button
type="button"
class="code-action-btn"
[disabled]="fontSize === defaultFontSize"
[attr.title]="tooltipFor(t('common.reset'))"
(click)="resetFontSize()"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M3 12a9 9 0 1 0 9-9a9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</g>
</svg>
</button>
<button
type="button"
class="code-action-btn"
[disabled]="fontSize >= 36"
[attr.title]="tooltipFor(t('common.increase'))"
(click)="changeFontSize(1)"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7v14" />
</svg>
</button>
</ng-container>
<button
*ngIf="resolvedShowCopyButton"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(copied ? t('common.copied') : t('common.copy'))"
[attr.aria-label]="copied ? t('common.copied') : t('common.copy')"
(click)="copyCode()"
>
<svg *ngIf="!copied" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</g>
</svg>
<svg *ngIf="copied" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5" />
</svg>
</button>
<button
*ngIf="resolvedShowExpandButton"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(expanded ? t('common.collapse') : t('common.expand'))"
[attr.aria-pressed]="expanded"
(click)="toggleExpanded()"
>
<svg *ngIf="expanded" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m14 10l7-7m-1 7h-6V4M3 21l7-7m-6 0h6v6" />
</svg>
<svg *ngIf="!expanded" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="code-action-btn__icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 3h6v6m0-6l-7 7M3 21l7-7m-1 7H3v-6" />
</svg>
</button>
<button
*ngIf="resolvedShowPreviewButton && isPreviewable"
type="button"
class="code-action-btn"
[attr.title]="tooltipFor(t('common.preview'))"
[attr.aria-label]="t('common.preview')"
(click)="previewCode()"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" aria-hidden="true" class="code-action-btn__icon">
<g fill="currentColor" fill-rule="evenodd" clip-rule="evenodd">
<path d="M23.628 7.41c-.12-1.172-.08-3.583-.9-4.233c-1.921-1.51-6.143-1.11-8.815-1.19c-3.481-.15-7.193.14-10.625.24a.34.34 0 0 0 0 .67c3.472-.05 7.074-.29 10.575-.09c2.471.15 6.653-.14 8.254 1.16c.4.33.41 2.732.49 3.582a42 42 0 0 1 .08 9.005a13.8 13.8 0 0 1-.45 3.001c-2.42 1.4-19.69 2.381-20.72.55a21 21 0 0 1-.65-4.632a41.5 41.5 0 0 1 .12-7.964c.08 0 7.334.33 12.586.24c2.331 0 4.682-.13 6.764-.21a.33.33 0 0 0 0-.66c-7.714-.16-12.897-.43-19.31.05c.11-1.38.48-3.922.38-4.002a.3.3 0 0 0-.42 0c-.37.41-.29 1.77-.36 2.251s-.14 1.07-.2 1.6a45 45 0 0 0-.36 8.645a21.8 21.8 0 0 0 .66 5.002c1.46 2.702 17.248 1.461 20.95.43c1.45-.4 1.69-.8 1.871-1.95c.575-3.809.602-7.68.08-11.496" />
<path d="M4.528 5.237a.84.84 0 0 0-.21-1c-.77-.41-1.71.39-1 1.1a.83.83 0 0 0 1.21-.1m2.632-.25c.14-.14.19-.84-.2-1c-.77-.41-1.71.39-1 1.09a.82.82 0 0 0 1.2-.09m2.88 0a.83.83 0 0 0-.21-1c-.77-.41-1.71.39-1 1.09a.82.82 0 0 0 1.21-.09m-4.29 8.735c0 .08.23 2.471.31 2.561a.371.371 0 0 0 .63-.14c0-.09 0 0 .15-1.72a10 10 0 0 0-.11-2.232a5.3 5.3 0 0 1-.26-1.37a.3.3 0 0 0-.54-.24a6.8 6.8 0 0 0-.2 2.33c-1.281-.38-1.121.13-1.131-.42a15 15 0 0 0-.19-1.93c-.16-.17-.36-.17-.51.14a20 20 0 0 0-.43 3.471c.04.773.18 1.536.42 2.272c.26.4.7.22.7-.1c0-.09-.16-.09 0-1.862c.06-1.18-.23-.3 1.16-.76m5.033-2.552c.32-.07.41-.28.39-.37c0-.55-3.322-.34-3.462-.24s-.2.18-.18.28s0 .11 0 .16a3.8 3.8 0 0 0 1.591.361v.82a15 15 0 0 0-.13 3.132c0 .2-.09.94.17 1.16a.34.34 0 0 0 .48 0c.125-.35.196-.718.21-1.09a8 8 0 0 0 .14-3.232c0-.13.05-.7-.1-.89a8 8 0 0 0 .89-.09m5.544-.181a.69.69 0 0 0-.89-.44a2.8 2.8 0 0 0-1.252 1.001a2.3 2.3 0 0 0-.41-.83a1 1 0 0 0-1.6.27a7 7 0 0 0-.35 2.07c0 .571 0 2.642.06 2.762c.14 1.09 1 .51.63.13a17.6 17.6 0 0 1 .38-3.962c.32-1.18.32.2.39.51s.11 1.081.73 1.081s.48-.93 1.401-1.78q.075 1.345 0 2.69a15 15 0 0 0 0 1.811a.34.34 0 0 0 .68 0q.112-.861.11-1.73a16.7 16.7 0 0 0 .12-3.582m1.441-.201c-.05.16-.3 3.002-.31 3.202a6.3 6.3 0 0 0 .21 1.741c.33 1 1.21 1.07 2.291.82a3.7 3.7 0 0 0 1.14-.23c.21-.22.10-.59-.41-.64q-.817.096-1.64.07c-.44-.07-.34 0-.67-4.442q.015-.185 0-.37a.316.316 0 0 0-.23-.38a.316.316 0 0 0-.38.23" />
</g>
</svg>
</button>
</div>
</div>
<div
class="code-block-body"
[class.code-block-body--collapsed]="collapsed"
[class.code-block-body--expanded]="expanded"
>
<ng-container *ngIf="!showLoadingPlaceholder; else loadingTpl">
<markstream-angular-pre-code-node *ngIf="useFallback; else editorTpl" [node]="node" />
<ng-template #editorTpl>
<div #editorHost class="code-editor-container" [style.visibility]="editorReady ? 'visible' : 'hidden'"></div>
<div *ngIf="!editorReady" style="position:absolute; inset:0; overflow:auto; padding:1rem;">
<pre class="code-fallback-plain m-0" [attr.aria-busy]="resolvedLoading" [attr.aria-label]="ariaLabel" tabindex="0"><code translate="no" [style.fontSize.px]="fontSize" [textContent]="resolvedCode"></code></pre>
</div>
</ng-template>
</ng-container>
</div>
<markstream-angular-html-preview-frame
*ngIf="inlinePreviewOpen && isPreviewable"
[code]="resolvedCode"
[isDark]="resolvedIsDark"
[title]="previewTitle"
[onClose]="closeInlinePreview"
/>
<span class="sr-only" aria-live="polite" role="status">{{ copied ? t('common.copied') : '' }}</span>
</div>
<ng-template #loadingTpl>
<div class="code-loading-placeholder">
<div class="loading-skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</div>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CodeBlockNodeComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef)
private readonly i18n = useSafeI18n()
@ViewChild('editorHost') private editorHost?: ElementRef<HTMLElement>
@Input({ required: true }) node!: AngularRenderableNode
@Input() context?: AngularRenderContext
@Input() props?: Record<string, any>
useFallback = false
copied = false
collapsed = false
expanded = false
inlinePreviewOpen = false
editorReady = false
defaultFontSize = 14
fontSize = 14
private helpers: MonacoHelpers | null = null
private createPromise: Promise<void> | null = null
private syncPromise: Promise<void> | null = null
private editorKind: 'single' | 'diff' | null = null
private viewReady = false
private destroyed = false
private copyTimer: number | null = null
private deferredHeightSyncRaf: number | null = null
get t() {
return this.i18n.t
}
get mergedProps() {
return {
...(this.context?.codeBlockProps || {}),
...(this.props || {}),
}
}
get resolvedLoading() {
const explicit = this.mergedProps.loading
if (typeof explicit === 'boolean')
return explicit
return (this.node as any)?.loading !== false
}
get resolvedStream() {
const explicit = this.mergedProps.stream
if (typeof explicit === 'boolean')
return explicit
if (typeof this.context?.codeBlockStream === 'boolean')
return this.context.codeBlockStream
return true
}
get resolvedIsDark() {
const explicit = this.mergedProps.isDark
if (typeof explicit === 'boolean')
return explicit
return this.context?.isDark === true
}
get resolvedDarkTheme() {
return this.mergedProps.darkTheme ?? this.context?.codeBlockThemes?.darkTheme ?? 'vitesse-dark'
}
get resolvedLightTheme() {
return this.mergedProps.lightTheme ?? this.context?.codeBlockThemes?.lightTheme ?? 'vitesse-light'
}
get resolvedThemes() {
return this.mergedProps.themes ?? this.context?.codeBlockThemes?.themes ?? ['vitesse-dark', 'vitesse-light']
}
get resolvedMonacoOptions() {
return {
...(this.context?.codeBlockThemes?.monacoOptions || {}),
...(this.mergedProps.monacoOptions || {}),
}
}
get resolvedEnableFontSizeControl() {
if (typeof this.mergedProps.enableFontSizeControl === 'boolean')
return this.mergedProps.enableFontSizeControl
return true
}
get resolvedShowHeader() {
return this.mergedProps.showHeader !== false
}
get resolvedShowCopyButton() {
return this.mergedProps.showCopyButton !== false
}
get resolvedShowExpandButton() {
return this.mergedProps.showExpandButton !== false
}
get resolvedShowPreviewButton() {
return this.mergedProps.showPreviewButton !== false
}
get resolvedShowCollapseButton() {
return this.mergedProps.showCollapseButton !== false
}
get resolvedShowFontSizeButtons() {
return this.mergedProps.showFontSizeButtons !== false
}
get resolvedShowTooltips() {
if (typeof this.mergedProps.showTooltips === 'boolean')
return this.mergedProps.showTooltips
return this.context?.showTooltips !== false
}
get resolvedIsShowPreview() {
if (typeof this.mergedProps.isShowPreview === 'boolean')
return this.mergedProps.isShowPreview
return true
}
get rawLanguage() {
return getString((this.node as any)?.language || 'plaintext')
}
get canonicalLanguage() {
return normalizeLanguageIdentifier(this.rawLanguage) || 'plain'
}
get monacoLanguage() {
return resolveMonacoLanguageId(this.rawLanguage)
}
get isPlainTextLanguage() {
return this.monacoLanguage === 'plaintext'
}
get displayLanguage() {
const label = languageMap[this.canonicalLanguage] || this.canonicalLanguage
return label ? label.charAt(0).toUpperCase() + label.slice(1) : 'Text'
}
get languageIconDataUrl() {
const icon = getLanguageIcon(this.rawLanguage).trim()
const svg = icon.includes('xmlns=')
? icon
: icon.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"')
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
get resolvedCode() {
if (this.isDiff)
return getString((this.node as any)?.updatedCode || (this.node as any)?.code)
return getString((this.node as any)?.code)
}
get originalCode() {
return getString((this.node as any)?.originalCode)
}
get isDiff() {
return !!(this.node as any)?.diff
}
get isPreviewable() {
if (!this.resolvedIsShowPreview)
return false
return this.canonicalLanguage === 'html' || this.canonicalLanguage === 'svg'
}
get previewTitle() {
return this.canonicalLanguage === 'html'
? this.t('artifacts.htmlPreviewTitle')
: this.t('artifacts.svgPreviewTitle')
}
get headerForeground() {
return `var(--vscode-editor-foreground, ${this.resolvedIsDark ? '#e5e7eb' : '#111827'})`
}
get headerBackground() {
return `var(--vscode-editor-background, ${this.resolvedIsDark ? '#111827' : '#ffffff'})`
}
get ariaLabel() {
return `Code block: ${this.displayLanguage}`
}
get showLoadingPlaceholder() {
return this.shouldDelayEditor
}
get shouldDelayEditor() {
return !this.resolvedStream && this.resolvedLoading
}
get containerStyle() {
const style: Record<string, string> = {}
const min = this.resolveCssSize(this.mergedProps.minWidth ?? this.context?.codeBlockThemes?.minWidth)
const max = this.resolveCssSize(this.mergedProps.maxWidth ?? this.context?.codeBlockThemes?.maxWidth)
if (min)
style.minWidth = min
if (max)
style.maxWidth = max
return style
}
readonly closeInlinePreview = () => {
this.inlinePreviewOpen = false
this.cdr.markForCheck()
}
ngAfterViewInit() {
this.viewReady = true
this.applyInitialFontSize()
void this.syncEditorState()
}
ngOnChanges() {
this.applyInitialFontSize()
if (!this.viewReady)
return
void this.syncEditorState()
}
ngOnDestroy() {
this.destroyed = true
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
this.cancelDeferredHeightSync()
this.cleanupEditor()
}
tooltipFor(text: string) {
return this.resolvedShowTooltips ? text : null
}
toggleCollapsed() {
this.collapsed = !this.collapsed
this.inlinePreviewOpen = false
if (!this.collapsed)
queueMicrotask(() => void this.syncEditorState())
this.cdr.markForCheck()
}
toggleExpanded() {
this.expanded = !this.expanded
queueMicrotask(() => this.applyEditorHeight())
this.cdr.markForCheck()
}
changeFontSize(delta: number) {
this.fontSize = Math.min(36, Math.max(10, this.fontSize + delta))
this.applyEditorFontSize()
this.applyEditorHeight()
this.cdr.markForCheck()
}
resetFontSize() {
this.fontSize = this.defaultFontSize
this.applyEditorFontSize()
this.applyEditorHeight()
this.cdr.markForCheck()
}
async copyCode() {
const text = this.resolvedCode
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText)
await navigator.clipboard.writeText(text)
else
this.copyUsingTextarea(text)
}
catch {
this.copyUsingTextarea(text)
}
this.copied = true
this.context?.events.onCopy?.(text)
if (this.copyTimer != null && typeof window !== 'undefined')
window.clearTimeout(this.copyTimer)
if (typeof window !== 'undefined') {
this.copyTimer = window.setTimeout(() => {
this.copied = false
this.cdr.markForCheck()
}, 1000)
}
this.cdr.markForCheck()
}
previewCode() {
if (!this.isPreviewable)
return
const artifactType = this.canonicalLanguage === 'html' ? 'text/html' : 'image/svg+xml'
const artifactTitle = this.previewTitle
if (typeof this.context?.events.onHandleArtifactClick === 'function') {
this.context.events.onHandleArtifactClick({
node: this.node,
artifactType,
artifactTitle,
id: `temp-${this.canonicalLanguage}-${Date.now()}`,
})
return
}
if (this.canonicalLanguage === 'html')
this.inlinePreviewOpen = !this.inlinePreviewOpen
this.cdr.markForCheck()
}
private applyInitialFontSize() {
const initial = Number(this.resolvedMonacoOptions.fontSize)
this.defaultFontSize = Number.isFinite(initial) && initial > 0 ? initial : 14
if (!(typeof this.fontSize === 'number' && Number.isFinite(this.fontSize) && this.fontSize > 0))
this.fontSize = this.defaultFontSize
if (this.fontSize < 10 || this.fontSize > 36)
this.fontSize = this.defaultFontSize
}
private async syncEditorState() {
if (this.destroyed || !this.viewReady)
return
if (this.collapsed || this.showLoadingPlaceholder) {
this.editorReady = false
this.cdr.markForCheck()
return
}
await this.ensureHelpers()
if (this.destroyed || this.useFallback || !this.helpers) {
this.cdr.markForCheck()
return
}
if (!this.editorHost?.nativeElement) {
this.editorReady = false
this.cdr.markForCheck()
return
}
if (this.syncPromise)
return this.syncPromise
this.syncPromise = (async () => {
try {
const desiredKind = this.isDiff && typeof this.helpers?.createDiffEditor === 'function' ? 'diff' : 'single'
if (this.editorKind !== desiredKind) {
await this.recreateEditor(desiredKind)
}
else {
await this.updateEditor()
}
await Promise.resolve(this.helpers?.setTheme?.(this.resolvedIsDark ? this.resolvedDarkTheme : this.resolvedLightTheme))
this.applyEditorFontSize()
if (this.expanded || !this.resolvedLoading || !this.editorReady)
this.applyEditorHeight()
this.editorReady = true
this.scheduleDeferredHeightSync()
}
catch {
this.useFallback = true
this.editorReady = false
this.cleanupEditor()
}
finally {
this.syncPromise = null
this.cdr.markForCheck()
}
})()
return this.syncPromise
}
private async ensureHelpers() {
if (this.helpers || this.useFallback)
return
if (this.createPromise)
return this.createPromise
this.createPromise = (async () => {
const monacoModule = await getUseMonaco()
if (!monacoModule || typeof monacoModule.useMonaco !== 'function') {
this.useFallback = true
return
}
const options = {
wordWrap: 'on',
wrappingIndent: 'same',
readOnly: true,
minimap: { enabled: false },
lineNumbers: 'on',
revealDebounceMs: 75,
MAX_HEIGHT: 500,
fontSize: this.defaultFontSize,
themes: this.resolvedThemes,
theme: this.resolvedIsDark ? this.resolvedDarkTheme : this.resolvedLightTheme,
...(this.resolvedMonacoOptions || {}),
}
this.helpers = monacoModule.useMonaco(options)
})()
try {
await this.createPromise
}
finally {
this.createPromise = null
}
}
private async recreateEditor(kind: 'single' | 'diff') {
const host = this.editorHost?.nativeElement
if (!host || !this.helpers)
return
this.cleanupEditor()
host.innerHTML = ''
this.editorKind = kind
if (kind === 'diff') {
await Promise.resolve(this.helpers.createDiffEditor?.(
host,
this.originalCode,
this.resolvedCode,
this.monacoLanguage,
))
return
}
await Promise.resolve(this.helpers.createEditor?.(
host,
this.resolvedCode,
this.monacoLanguage,
))
}
private async updateEditor() {
if (!this.helpers)
return
if (this.isDiff && this.editorKind === 'diff' && typeof this.helpers.updateDiff === 'function') {
await Promise.resolve(this.helpers.updateDiff(this.originalCode, this.resolvedCode, this.monacoLanguage))
return
}
await Promise.resolve(this.helpers.updateCode?.(this.resolvedCode, this.monacoLanguage))
}
private applyEditorFontSize() {
const view = this.editorKind === 'diff'
? this.helpers?.getDiffEditorView?.()
: this.helpers?.getEditorView?.()
try {
view?.updateOptions?.({ fontSize: this.fontSize, automaticLayout: this.expanded })
if (this.editorKind === 'diff' && typeof view?.getModifiedEditor === 'function')
view.getModifiedEditor()?.updateOptions?.({ fontSize: this.fontSize })
}
catch {}
}
private applyEditorHeight() {
const host = this.editorHost?.nativeElement
if (!host)
return
const maxHeight = this.resolveMaxHeight()
host.style.minHeight = '0px'
host.style.maxHeight = this.expanded ? 'none' : `${maxHeight}px`
host.style.overflow = this.expanded ? 'visible' : 'auto'
const view = this.editorKind === 'diff'
? this.helpers?.getDiffEditorView?.()
: this.helpers?.getEditorView?.()
if (!view)
return
try {
view.updateOptions?.({ automaticLayout: this.expanded })
}
catch {}
const height = this.resolveEditorHeight(view, maxHeight)
host.style.height = `${height}px`
try {
view.layout?.()
}
catch {}
}
private resolveEditorHeight(view: any, maxHeight: number) {
let contentHeight = Number.NaN
try {
if (typeof view.getContentHeight === 'function')
contentHeight = Number(view.getContentHeight())
else if (this.editorKind === 'diff' && typeof view.getModifiedEditor === 'function')
contentHeight = Number(view.getModifiedEditor()?.getContentHeight?.())
}
catch {}
if (!Number.isFinite(contentHeight) || contentHeight <= 0) {
const lines = this.resolveEditorLineCount(view)
const lineHeight = this.resolveEditorLineHeight()
const estimate = lines * (lineHeight + 1.5) + 1
return Math.ceil(Math.max(1, this.expanded ? estimate : Math.min(estimate, maxHeight)))
}
return Math.ceil(Math.max(1, this.expanded ? contentHeight : Math.min(contentHeight, maxHeight)))
}
private resolveEditorLineCount(view: any) {
try {
if (
this.editorKind === 'diff'
&& typeof view?.getOriginalEditor === 'function'
&& typeof view?.getModifiedEditor === 'function'
) {
const originalLines = Number(view.getOriginalEditor?.()?.getModel?.()?.getLineCount?.() ?? 1)
const modifiedLines = Number(view.getModifiedEditor?.()?.getModel?.()?.getLineCount?.() ?? 1)
return Math.max(1, originalLines, modifiedLines)
}
const lines = Number(view?.getModel?.()?.getLineCount?.() ?? 1)
if (Number.isFinite(lines) && lines > 0)
return lines
}
catch {}
return Math.max(1, String(this.resolvedCode || '').split('\n').length)
}
private resolveEditorLineHeight() {
const fromOptions = Number(this.resolvedMonacoOptions.lineHeight)
if (Number.isFinite(fromOptions) && fromOptions > 0)
return fromOptions
const fromFontOption = Number(this.resolvedMonacoOptions.fontSize)
if (Number.isFinite(fromFontOption) && fromFontOption > 0)
return Math.max(12, Math.round(fromFontOption * 1.35))
const fromState = Number(this.fontSize)
if (Number.isFinite(fromState) && fromState > 0)
return Math.max(12, Math.round(fromState * 1.35))
return 18
}
private resolveMaxHeight() {
const raw = this.resolvedMonacoOptions.MAX_HEIGHT ?? 500
if (typeof raw === 'number' && Number.isFinite(raw))
return raw > 0 ? raw : 500
const matched = String(raw).match(/^(\d+(?:\.\d+)?)/)
const parsed = matched ? Number.parseFloat(matched[1]) : 500
return Number.isFinite(parsed) && parsed > 0 ? parsed : 500
}
private cancelDeferredHeightSync() {
if (this.deferredHeightSyncRaf == null || typeof window === 'undefined')
return
window.cancelAnimationFrame(this.deferredHeightSyncRaf)
this.deferredHeightSyncRaf = null
}
private scheduleDeferredHeightSync() {
if (typeof window === 'undefined' || this.destroyed)
return
this.cancelDeferredHeightSync()
this.deferredHeightSyncRaf = window.requestAnimationFrame(() => {
this.deferredHeightSyncRaf = window.requestAnimationFrame(() => {
this.deferredHeightSyncRaf = null
this.applyEditorHeight()
})
})
}
private cleanupEditor() {
try {
this.helpers?.safeClean?.()
}
catch {}
try {
this.helpers?.cleanupEditor?.()
}
catch {}
this.editorKind = null
}
private resolveCssSize(value: unknown) {
if (value == null || value === '')
return null
return typeof value === 'number' ? `${value}px` : String(value)
}
private copyUsingTextarea(text: string) {
if (typeof document === 'undefined')
return
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
textarea.style.pointerEvents = 'none'
document.body.appendChild(textarea)
textarea.select()
try {
document.execCommand('copy')
}
catch {}
finally {
textarea.remove()
}
}
}
@@ -0,0 +1,87 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { useSafeI18n } from '../../i18n/useSafeI18n'
@Component({
selector: 'markstream-angular-html-preview-frame',
standalone: true,
imports: [CommonModule],
template: `
<div
class="html-preview-frame__backdrop"
[class.html-preview-frame__backdrop--dark]="isDark"
(click)="onClose?.()"
>
<div
class="html-preview-frame"
[class.html-preview-frame--dark]="isDark"
(click)="$event.stopPropagation()"
>
<div class="html-preview-frame__header">
<div class="html-preview-frame__title">
<span class="html-preview-frame__dot"></span>
<span class="html-preview-frame__label">{{ resolvedTitle }}</span>
</div>
<button
type="button"
class="html-preview-frame__close"
[class.html-preview-frame__close--dark]="isDark"
(click)="onClose?.()"
>
×
</button>
</div>
<iframe
class="html-preview-frame__iframe"
sandbox="allow-scripts allow-same-origin"
[srcdoc]="srcdoc"
[title]="resolvedTitle"
></iframe>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HtmlPreviewFrameComponent {
@Input() code = ''
@Input() isDark = false
@Input() title?: string
@Input() onClose?: () => void
private readonly i18n = useSafeI18n()
get resolvedTitle() {
return this.title || `HTML ${this.i18n.t('common.preview')}`
}
get srcdoc() {
const base = this.code || ''
const lowered = base.trim().toLowerCase()
if (lowered.startsWith('<!doctype') || lowered.startsWith('<html') || lowered.startsWith('<body'))
return base
const bg = this.isDark ? '#020617' : '#ffffff'
const fg = this.isDark ? '#e5e7eb' : '#020617'
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background-color: ${bg};
color: ${fg};
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text', ui-sans-serif, sans-serif;
}
</style>
</head>
<body>
${base}
</body>
</html>`
}
}