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,184 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import NodeRenderer from '../NodeRenderer'
export type AdmonitionKind = 'note' | 'info' | 'tip' | 'warning' | 'danger' | 'caution' | 'error'
interface AdmonitionNode {
type: 'admonition'
kind: AdmonitionKind
title?: string
children: { type: string, raw: string }[]
raw: string
collapsible?: boolean
open?: boolean
}
const props = defineProps<{ node: AdmonitionNode, indexKey: number | string, isDark?: boolean, typewriter?: boolean, customId?: string }>()
const emit = defineEmits(['copy'])
const displayTitle = computed(() => {
if (props.node.title && props.node.title.trim().length)
return props.node.title
const k = props.node.kind || 'note'
return k.charAt(0).toUpperCase() + k.slice(1)
})
const collapsed = ref<boolean>(props.node.collapsible ? !(props.node.open ?? true) : false)
function toggleCollapse() {
if (!props.node.collapsible)
return
collapsed.value = !collapsed.value
}
const headerId = `admonition-${Math.random().toString(36).slice(2, 9)}`
</script>
<template>
<div class="admonition" :class="[`admonition-${props.node.kind}`]">
<!-- Legend-style title that sits on the border -->
<div :id="headerId" class="admonition-legend">
<!-- SVG icons per type -->
<svg v-if="props.node.kind === 'note' || props.node.kind === 'info'" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="admonition-icon"><circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" /></svg>
<svg v-else-if="props.node.kind === 'tip'" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="admonition-icon"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" /><path d="M9 18h6" /><path d="M10 22h4" /></svg>
<svg v-else-if="props.node.kind === 'warning' || props.node.kind === 'caution'" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="admonition-icon"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>
<svg v-else-if="props.node.kind === 'danger' || props.node.kind === 'error'" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="admonition-icon"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2" /><path d="M12 8v4" /><path d="M12 16h.01" /></svg>
<span class="admonition-title">{{ displayTitle }}</span>
<button
v-if="props.node.collapsible"
class="admonition-toggle"
:aria-expanded="!collapsed"
:aria-controls="`${headerId}-content`"
@click="toggleCollapse"
>
<svg :style="{ rotate: collapsed ? '0deg' : '90deg' }" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
</button>
</div>
<div
v-show="!collapsed"
:id="`${headerId}-content`"
class="admonition-content"
:aria-labelledby="headerId"
>
<NodeRenderer
:index-key="`admonition-${indexKey}`"
:nodes="props.node.children"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="emit('copy', $event)"
/>
</div>
</div>
</template>
<style scoped>
/* ── Base: fieldset/legend style ── */
.admonition {
position: relative;
margin: var(--ms-flow-admonition-y) 0;
padding: 0.25em 0.75em 0.375em;
border: 1px solid var(--admonition-border);
border-radius: var(--ms-radius);
color: var(--admonition-fg);
}
/* ── Legend: sits on top border ── */
.admonition-legend {
position: absolute;
top: 0;
left: 0.75em;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
gap: 0.35em;
padding: 0 0.5em;
background-color: hsl(var(--ms-background));
font-size: 0.8125rem;
font-weight: 600;
line-height: 1;
}
.admonition-icon {
flex-shrink: 0;
}
.admonition-title {
white-space: nowrap;
}
/* ── Content: top padding accounts for legend height ── */
.admonition-content {
padding-top: 0.25em;
color: var(--admonition-fg);
}
/* ── Type variants: muted border + subtle tint bg + saturated legend ── */
.admonition-note,
.admonition-info {
border-color: hsl(var(--ms-info) / 0.3);
background-color: hsl(var(--ms-info) / 0.04);
}
.admonition-note .admonition-legend,
.admonition-info .admonition-legend {
color: var(--admonition-note);
}
.admonition-tip {
border-color: hsl(var(--ms-success) / 0.3);
background-color: hsl(var(--ms-success) / 0.04);
}
.admonition-tip .admonition-legend {
color: var(--admonition-tip);
}
.admonition-warning,
.admonition-caution {
border-color: hsl(var(--ms-warning) / 0.3);
background-color: hsl(var(--ms-warning) / 0.04);
}
.admonition-warning .admonition-legend,
.admonition-caution .admonition-legend {
color: var(--admonition-warning);
}
.admonition-danger,
.admonition-error {
border-color: hsl(var(--ms-destructive) / 0.3);
background-color: hsl(var(--ms-destructive) / 0.04);
}
.admonition-danger .admonition-legend,
.admonition-error .admonition-legend {
color: var(--admonition-danger);
}
/* ── Collapse toggle ── */
.admonition-toggle {
margin-left: 0.25em;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
padding: 0.125rem;
border-radius: calc(var(--ms-radius) * 0.5);
display: inline-flex;
align-items: center;
transition: background-color var(--ms-duration-fast) var(--ms-ease-standard);
}
.admonition-toggle:hover {
background-color: hsl(var(--ms-accent));
}
.admonition-toggle:focus-visible {
outline: var(--ms-focus-ring-width) solid var(--focus-ring);
outline-offset: var(--ms-focus-ring-offset);
}
/* ── NodeRenderer inside admonition ── */
.admonition-content :deep(.markdown-renderer) {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0px 0px;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import AdmonitionNode from './AdmonitionNode.vue'
AdmonitionNode.install = (app: App) => {
app.component(AdmonitionNode.__name as string, AdmonitionNode)
}
export default AdmonitionNode
@@ -0,0 +1,61 @@
<script setup lang="ts">
import NodeRenderer from '../NodeRenderer'
// child node shape used across many node components
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
interface BlockquoteNode {
type: 'blockquote'
children: NodeChild[]
raw: string
// optional citation/source for the blockquote
cite?: string
}
const props = defineProps<{
node: BlockquoteNode
indexKey: string | number
typewriter?: boolean
customId?: string
}>()
// typed emit for better DX and type-safety when forwarding copy events
defineEmits<{
copy: [text: string]
}>()
</script>
<template>
<blockquote class="blockquote" dir="auto" :cite="node.cite">
<NodeRenderer
:index-key="`blockquote-${props.indexKey}`"
:nodes="props.node.children || []"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
</blockquote>
</template>
<style scoped>
.blockquote {
font-weight: 400;
font-style: normal;
color: var(--blockquote-fg, hsl(var(--ms-muted-foreground)));
border-left: 3px solid var(--blockquote-border);
margin-top: var(--ms-flow-blockquote-y);
margin-bottom: var(--ms-flow-blockquote-y);
padding-left: var(--ms-flow-blockquote-indent);
}
/* 防止内部 NodeRenderer 使用 content-visibility: auto 时在大文档滚动中出现“高但空白”的占位 */
.blockquote :deep(.markdown-renderer) {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0px 0px;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import BlockquoteNode from './BlockquoteNode.vue'
BlockquoteNode.install = (app: App) => {
app.component(BlockquoteNode.__name as string, BlockquoteNode)
}
export default BlockquoteNode
@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
node: {
type: 'checkbox'
checked: boolean
raw: string
}
}>()
</script>
<template>
<span class="checkbox-node" role="img" :aria-label="node.checked ? 'checked' : 'unchecked'">
<!-- Unchecked -->
<svg v-if="!node.checked" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" class="checkbox-icon checkbox-unchecked">
<rect x="3" y="3" width="18" height="18" rx="4" stroke="currentColor" stroke-width="2" />
</svg>
<!-- Checked -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" class="checkbox-icon checkbox-checked">
<rect x="3" y="3" width="18" height="18" rx="4" fill="currentColor" />
<path d="M9 12l2 2 4-4" stroke="hsl(var(--ms-background))" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</template>
<style scoped>
.checkbox-node {
display: inline-flex;
align-items: center;
margin-right: 0.5em;
vertical-align: -0.15em;
}
.checkbox-icon {
flex-shrink: 0;
}
.checkbox-unchecked {
color: hsl(var(--ms-muted-foreground) / 0.5);
}
.checkbox-checked {
color: hsl(var(--ms-info));
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import CheckboxNode from './CheckboxNode.vue'
CheckboxNode.install = (app: App) => {
app.component(CheckboxNode.__name as string, CheckboxNode)
}
export default CheckboxNode
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,405 @@
<script setup lang="ts">
/**
* CodeBlockShell — shared header/buttons/skeleton/collapse wrapper
* for all built-in code block renderers (Monaco, Shiki, Pre).
*
* This is a "dumb" template component. All state lives in the parent;
* Shell receives it via props and communicates back via emits.
*/
import { computed, ref } from 'vue'
import { useSafeI18n } from '../../composables/useSafeI18n'
import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip'
const props = withDefaults(defineProps<{
// Header visibility
showHeader?: boolean
showCollapseButton?: boolean
showFontSizeButtons?: boolean
enableFontSizeControl?: boolean
showCopyButton?: boolean
showExpandButton?: boolean
showPreviewButton?: boolean
showTooltips?: boolean
// State (from parent)
isDark?: boolean
loading?: boolean
stream?: boolean
isCollapsed?: boolean
isExpanded?: boolean
copyText?: boolean
isPreviewable?: boolean
// Font size state
codeFontSize?: number
codeFontMin?: number
codeFontMax?: number
defaultCodeFontSize?: number
fontBaselineReady?: boolean
// Diff stats (optional, CodeBlockNode only)
diffStats?: { added: number, removed: number } | null
diffStatsAriaLabel?: string
}>(), {
showHeader: true,
showCollapseButton: true,
showCopyButton: true,
showExpandButton: true,
showPreviewButton: true,
showTooltips: true,
isDark: false,
loading: false,
stream: false,
isCollapsed: false,
isExpanded: false,
copyText: false,
isPreviewable: false,
enableFontSizeControl: true,
showFontSizeButtons: true,
fontBaselineReady: false,
})
const emit = defineEmits<{
(e: 'toggleCollapse'): void
(e: 'decreaseFont'): void
(e: 'resetFont'): void
(e: 'increaseFont'): void
(e: 'copy'): void
(e: 'toggleExpand', event: MouseEvent): void
(e: 'preview'): void
}>()
const moreMenuOpen = ref(false)
const moreMenuRef = ref<HTMLElement | null>(null)
const moreBtnRef = ref<HTMLElement | null>(null)
function toggleMoreMenu() {
moreMenuOpen.value = !moreMenuOpen.value
if (moreMenuOpen.value) {
document.addEventListener('click', closeMoreMenuOutside, { once: true, capture: true })
}
}
function closeMoreMenuOutside(e: Event) {
const target = e.target as Node
if (moreMenuRef.value?.contains(target) || moreBtnRef.value?.contains(target)) {
document.addEventListener('click', closeMoreMenuOutside, { once: true, capture: true })
}
else {
moreMenuOpen.value = false
}
}
// Whether the overflow menu has any items to show
const hasOverflowItems = computed(() =>
(props.showFontSizeButtons && props.enableFontSizeControl)
|| props.showExpandButton
|| (props.isPreviewable && props.showPreviewButton),
)
const { t } = useSafeI18n()
const tooltipsEnabled = computed(() => props.showTooltips !== false)
function onBtnHover(e: MouseEvent | FocusEvent, text: string) {
if (!tooltipsEnabled.value)
return
showTooltipForAnchor(e.currentTarget as HTMLElement, text, 'top', false, undefined, props.isDark)
}
function onBtnLeave() {
if (!tooltipsEnabled.value)
return
hideTooltip()
}
function onCopyHover(e: MouseEvent | FocusEvent) {
onBtnHover(e, props.copyText ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy'))
}
const fontDecreaseDisabled = computed(() =>
Number.isFinite(props.codeFontSize) ? (props.codeFontSize ?? 0) <= (props.codeFontMin ?? 0) : false,
)
const fontResetDisabled = computed(() =>
!props.fontBaselineReady || props.codeFontSize === props.defaultCodeFontSize,
)
const fontIncreaseDisabled = computed(() =>
Number.isFinite(props.codeFontSize) ? (props.codeFontSize ?? 0) >= (props.codeFontMax ?? 100) : false,
)
</script>
<template>
<!-- Header -->
<div
v-if="props.showHeader"
class="code-block-header flex justify-between items-center border-b px-[var(--ms-inset-panel-x)] py-[var(--ms-inset-panel-y)] border-[var(--code-border)] bg-[var(--code-header-bg)] text-[var(--code-fg)]"
>
<!-- left: language icon + title (provided by parent via slot) -->
<slot name="header-left" />
<!-- right: action buttons -->
<slot name="header-right">
<div class="flex items-center gap-0.5">
<!-- Diff stats (optional) -->
<div
v-if="diffStats"
class="code-diff-stats"
:aria-label="diffStatsAriaLabel"
>
<span class="code-diff-stat removed">-{{ diffStats.removed }}</span>
<span class="code-diff-stat added">+{{ diffStats.added }}</span>
</div>
<!-- Copy (primary action — always visible) -->
<button
v-if="props.showCopyButton"
type="button"
class="code-action-btn inline-flex items-center justify-center p-[var(--ms-action-btn-padding)] rounded leading-none shrink-0 cursor-pointer text-[var(--code-action-fg)] hover:bg-[var(--code-action-hover-bg)] hover:text-[var(--code-action-hover-fg)] active:scale-[0.96] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
:aria-label="copyText ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')"
@click="emit('copy')"
@mouseenter="onCopyHover($event)"
@focus="onCopyHover($event)"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg v-if="!copyText" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="action-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 v-else xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5" /></svg>
</button>
<!-- Collapse (structural — always visible) -->
<button
v-if="props.showCollapseButton"
type="button"
class="code-action-btn inline-flex items-center justify-center p-[var(--ms-action-btn-padding)] rounded leading-none shrink-0 cursor-pointer text-[var(--code-action-fg)] hover:bg-[var(--code-action-hover-bg)] hover:text-[var(--code-action-hover-fg)] active:scale-[0.96] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
:aria-pressed="isCollapsed"
@click="emit('toggleCollapse')"
@mouseenter="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@focus="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg :style="{ rotate: isCollapsed ? '0deg' : '90deg' }" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 18l6-6l-6-6" /></svg>
</button>
<!-- More menu (overflow for secondary actions) -->
<div v-if="hasOverflowItems" class="relative">
<button
ref="moreBtnRef"
type="button"
class="code-action-btn inline-flex items-center justify-center p-[var(--ms-action-btn-padding)] rounded leading-none shrink-0 cursor-pointer text-[var(--code-action-fg)] hover:bg-[var(--code-action-hover-bg)] hover:text-[var(--code-action-hover-fg)] active:scale-[0.96] transition-colors"
:aria-expanded="moreMenuOpen"
aria-haspopup="true"
@click.stop="toggleMoreMenu"
@mouseenter="onBtnHover($event, t('common.more') || 'More')"
@focus="onBtnHover($event, t('common.more') || 'More')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><g fill="currentColor"><circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" /></g></svg>
</button>
<Transition name="code-menu">
<div v-if="moreMenuOpen" ref="moreMenuRef" class="code-more-menu min-w-[10rem] p-1 bg-[hsl(var(--ms-popover))] text-[hsl(var(--ms-popover-foreground))] border border-[var(--code-border)] shadow-[var(--ms-shadow-popover)]" role="menu">
<!-- Font size controls -->
<template v-if="props.showFontSizeButtons && props.enableFontSizeControl">
<button type="button" role="menuitem" class="flex items-center gap-2 w-full py-1.5 px-2 rounded text-xs text-[var(--code-action-fg)] cursor-pointer whitespace-nowrap hover:bg-[var(--code-action-hover-bg)] hover:text-[var(--code-action-hover-fg)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors" :disabled="fontDecreaseDisabled" @click="emit('decreaseFont')">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14" /></svg>
<span>{{ t('common.fontSmaller') || 'Font size −' }}</span>
</button>
<button type="button" role="menuitem" class="flex items-center gap-2 w-full py-1.5 px-2 rounded text-xs text-[var(--code-action-fg)] cursor-pointer whitespace-nowrap hover:bg-[var(--code-action-hover-bg)] hover:text-[var(--code-action-hover-fg)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors" :disabled="fontResetDisabled" @click="emit('resetFont')">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="action-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>
<span>{{ t('common.fontReset') || 'Font size reset' }}</span>
</button>
<button type="button" role="menuitem" class="flex items-center gap-2 w-full py-1.5 px-2 rounded text-xs text-[var(--code-action-fg)] cursor-pointer whitespace-nowrap hover:bg-[var(--code-action-hover-bg)] hover:text-[var(--code-action-hover-fg)] disabled:opacity-40 disabled:cursor-not-allowed transition-colors" :disabled="fontIncreaseDisabled" @click="emit('increaseFont')">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7-7v14" /></svg>
<span>{{ t('common.fontLarger') || 'Font size +' }}</span>
</button>
</template>
<!-- Expand -->
<button v-if="props.showExpandButton" type="button" role="menuitem" class="flex items-center gap-2 w-full py-1.5 px-2 rounded text-xs text-[var(--code-action-fg)] cursor-pointer whitespace-nowrap hover:bg-[var(--code-action-hover-bg)] hover:text-[var(--code-action-hover-fg)] transition-colors" @click="emit('toggleExpand', $event); moreMenuOpen = false">
<svg v-if="isExpanded" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="action-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 v-else xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="action-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>
<span>{{ isExpanded ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand') }}</span>
</button>
<!-- Preview -->
<button v-if="isPreviewable && props.showPreviewButton" type="button" role="menuitem" class="flex items-center gap-2 w-full py-1.5 px-2 rounded text-xs text-[var(--code-action-fg)] cursor-pointer whitespace-nowrap hover:bg-[var(--code-action-hover-bg)] hover:text-[var(--code-action-hover-fg)] transition-colors" @click="emit('preview'); moreMenuOpen = false">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></g></svg>
<span>{{ t('common.preview') || 'Preview' }}</span>
</button>
</div>
</Transition>
</div>
</div>
</slot>
</div>
<!-- Content: keep slot subtree mounted so Monaco/renderer state survives collapse. -->
<div v-show="!isCollapsed && (stream ? true : !loading)" class="code-block-shell-content">
<slot />
</div>
<!-- Loading skeleton -->
<div v-show="!stream && loading" class="code-loading-placeholder">
<slot name="loading">
<div class="loading-skeleton">
<div class="skeleton-line" />
<div class="skeleton-line" />
<div class="skeleton-line short" />
</div>
</slot>
</div>
<!-- Screen reader copy status -->
<span class="sr-only" aria-live="polite" role="status">{{ copyText ? t('common.copied') || 'Copied' : '' }}</span>
</template>
<!--
Shared styles for CodeBlockShell and its slot content.
Non-scoped so they apply to parent-compiled slot content.
All selectors rooted under .code-block-header to avoid global leaks.
-->
<style>
.action-icon {
width: var(--ms-action-btn-icon);
height: var(--ms-action-btn-icon);
}
/* ── Container base (shared by all code block variants) ── */
.code-block-container {
margin: var(--ms-flow-codeblock-y) 0;
contain: layout style;
container-type: inline-size;
background: var(--code-bg);
border-color: var(--code-border);
color: var(--code-fg);
box-shadow: var(--ms-shadow-subtle);
}
/* ── Header layout ── */
.code-block-header {
position: relative;
z-index: 1;
gap: var(--ms-gap-header);
border-radius: var(--ms-radius) var(--ms-radius) 0 0;
overflow: visible;
}
.code-block-header .code-header-main {
min-width: 0;
flex: 1 1 auto;
display: flex;
align-items: center;
gap: var(--ms-gap-header-main);
overflow: hidden;
}
.code-block-header .code-header-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.code-block-header .code-header-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--ms-text-label);
font-weight: 500;
color: var(--code-action-fg);
}
.code-block-header .code-header-caption {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--code-line-number);
}
.code-block-header .code-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--ms-gap-header-actions);
flex-wrap: wrap;
}
/* ── Icon slot (shared by all code block variants) ── */
.code-block-header .icon-slot {
display: inline-flex;
align-items: center;
justify-content: center;
}
.code-block-header .icon-slot svg,
.code-block-header .icon-slot img {
display: block;
width: 100%;
height: 100%;
}
/* ── Diff stats ── */
.code-diff-stats {
display: inline-flex;
align-items: center;
gap: var(--ms-space-1_5);
margin-right: var(--ms-space-1);
font-size: var(--ms-text-label);
font-weight: 600;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.code-diff-stat {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: var(--ms-radius);
line-height: 1;
}
.code-diff-stat.removed {
color: var(--diff-removed-fg);
background: hsl(var(--ms-diff-removed) / 0.1);
}
.code-diff-stat.added {
color: var(--diff-added-fg);
background: hsl(var(--ms-diff-added) / 0.1);
}
/* ── Dropdown menu ── */
.code-more-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
z-index: 50;
border-radius: var(--ms-radius);
}
/* ── Content / loading — clip to bottom border-radius ── */
.code-block-shell-content,
.code-loading-placeholder {
overflow: hidden;
border-radius: 0 0 var(--ms-radius) var(--ms-radius);
contain: content;
}
/* ── Menu transition ── */
.code-menu-enter-active,
.code-menu-leave-active {
transform-origin: top right;
}
.code-menu-enter-active {
transition: opacity 220ms cubic-bezier(.16, 1, .3, 1),
transform 220ms cubic-bezier(.16, 1, .3, 1);
}
.code-menu-leave-active {
transition: opacity 140ms ease-in,
transform 140ms ease-in;
}
.code-menu-enter-from {
opacity: 0;
transform: scale(0.9) translateY(-4px);
}
.code-menu-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-2px);
}
</style>
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { useSafeI18n } from '../../composables/useSafeI18n'
const props = defineProps<{
code: string
isDark?: boolean
onClose?: () => void
title?: string
}>()
const { t } = useSafeI18n()
const srcdoc = computed(() => {
const base = props.code || ''
// 如果用户已经自己写了 <html>/<body>,直接原样输出,避免重复包裹
const lowered = base.trim().toLowerCase()
if (lowered.startsWith('<!doctype') || lowered.startsWith('<html') || lowered.startsWith('<body'))
return base
const bg = props.isDark ? '#020617' : '#ffffff'
const fg = props.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>`
})
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' || e.key === 'Esc')
props.onClose?.()
}
onMounted(() => {
if (typeof window !== 'undefined')
window.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
if (typeof window !== 'undefined')
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<teleport to="body">
<div class="markstream-vue" :class="{ dark: props.isDark }">
<div class="html-preview-frame__backdrop" @click="props.onClose?.()">
<div class="html-preview-frame" @click.stop>
<div class="html-preview-frame__header">
<div class="html-preview-frame__title">
<span class="html-preview-frame__dot" />
<span class="html-preview-frame__label">{{ props.title || t('common.preview') || 'Preview' }}</span>
</div>
<button
type="button"
class="html-preview-frame__close"
@click="props.onClose?.()"
>
×
</button>
</div>
<iframe
class="html-preview-frame__iframe"
sandbox="allow-scripts allow-same-origin"
:srcdoc="srcdoc"
/>
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
.html-preview-frame__backdrop {
position: fixed;
inset: 0;
background-color: var(--modal-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.html-preview-frame {
width: 80vw;
max-width: 960px;
height: 70vh;
background-color: var(--modal-bg);
color: var(--modal-fg);
border-radius: calc(var(--ms-radius) * 2);
overflow: hidden;
box-shadow: var(--ms-shadow-preview);
display: flex;
flex-direction: column;
}
.html-preview-frame__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid var(--code-border);
}
.html-preview-frame__title {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
opacity: 0.85;
}
.html-preview-frame__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background-color: hsl(var(--ms-success));
}
.html-preview-frame__label {
white-space: nowrap;
}
.html-preview-frame__close {
border: none;
background: transparent;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
color: var(--modal-fg);
}
.html-preview-frame__iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
@media (max-width: 640px) {
.html-preview-frame {
width: 100vw;
height: 80vh;
border-radius: 0;
}
}
</style>
@@ -0,0 +1,40 @@
# CodeBlockNode (component)
`CodeBlockNode` renders rich interactive code blocks. It supports Monaco streaming integration (optional), Markdown mode, and a flexible header API with slots and events.
Quick example — inline usage (fallback to a simple rendering if Monaco not installed):
```vue
<CodeBlockNode :node="{ type: 'code_block', language: 'js', code: 'console.log(1)', raw: 'console.log(1)' }" />
```
Header override example:
```vue
<CodeBlockNode :node="node" :showCopyButton="false">
<template #header-left>
<div class="text-sm font-medium">My snippet</div>
</template>
<template #header-right>
<button @click="run">Run</button>
</template>
</CodeBlockNode>
```
Docs and usage examples:
- Docs: /guide/code-block-node
- Header API: /guide/codeblock-header
Monaco diff notes:
- `monacoOptions` is typed as `CodeBlockMonacoOptions`
- diff-friendly options such as `diffHideUnchangedRegions`, `diffLineStyle`, `diffAppearance`, `diffUnchangedRegionStyle`, `diffHunkActionsOnHover`, `diffHunkHoverHideDelayMs`, and `onDiffHunkAction` belong there
- when `node.diff === true`, `CodeBlockNode` now defaults to:
- `diffHideUnchangedRegions: { enabled: true, contextLineCount: 2, minimumLineCount: 4, revealLineCount: 5 }`
- `diffLineStyle: 'background'`
- `diffAppearance: 'auto'`
- `diffUnchangedRegionStyle: 'line-info'`
- `diffHunkActionsOnHover: true`
- `diffHunkHoverHideDelayMs: 160`
- runtime note: `diffAppearance: 'auto'` resolves to the current light/dark surface before it is passed to `stream-monaco`
- the header also shows `- / +` line counts for diff blocks
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import CodeBlockNode from './CodeBlockNode.vue'
CodeBlockNode.install = (app: App) => {
app.component(CodeBlockNode.__name as string, CodeBlockNode)
}
export default CodeBlockNode
@@ -0,0 +1,115 @@
import type { CodeBlockMonacoOptions, CodeBlockMonacoTheme } from '../../types/component-props'
import { preload } from '../NodeRenderer/preloadMonaco'
import { markCodeBlockRuntimeReady } from './runtime'
export { isCodeBlockRuntimeReady } from './runtime'
export interface MonacoDisposableLike {
dispose?: () => void
}
export interface MonacoModelLike {
getLineCount?: () => number
}
export interface MonacoEditorViewLike {
getModel?: () => MonacoModelLike | null | undefined
getOption?: (option: unknown) => unknown
updateOptions?: (options: Record<string, unknown>) => void
layout?: () => void
getContentHeight?: () => number
onDidContentSizeChange?: (listener: () => void) => MonacoDisposableLike | void
onDidLayoutChange?: (listener: () => void) => MonacoDisposableLike | void
}
export interface MonacoDiffLineChangeLike {
originalStartLineNumber?: number
originalEndLineNumber?: number
modifiedStartLineNumber?: number
modifiedEndLineNumber?: number
}
export interface MonacoDiffEditorViewLike extends MonacoEditorViewLike {
getOriginalEditor?: () => MonacoEditorViewLike | null | undefined
getModifiedEditor?: () => MonacoEditorViewLike | null | undefined
getLineChanges?: () => MonacoDiffLineChangeLike[] | null | undefined
onDidUpdateDiff?: (listener: () => void) => MonacoDisposableLike | void
}
export interface MonacoNamespaceLike {
EditorOption?: {
fontInfo?: unknown
lineHeight?: unknown
}
}
export interface MonacoRuntimeOptions extends Omit<CodeBlockMonacoOptions, 'theme'> {
theme?: CodeBlockMonacoTheme
themes?: CodeBlockMonacoTheme[]
onThemeChange?: () => void
}
export 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
getEditor?: () => MonacoNamespaceLike | null
getEditorView?: () => MonacoEditorViewLike | null
getDiffEditorView?: () => MonacoDiffEditorViewLike | null
cleanupEditor?: () => void
safeClean?: () => void
refreshDiffPresentation?: () => void
setTheme?: (theme: CodeBlockMonacoTheme | undefined) => Promise<void> | void
}
export interface MonacoModule {
useMonaco?: (options: MonacoRuntimeOptions) => MonacoHelpers | null | undefined
detectLanguage?: (code: string) => string
preloadMonacoWorkers?: () => Promise<unknown> | unknown
}
let mod: MonacoModule | null = null
let importFailed = false
let loadingPromise: Promise<MonacoModule | null> | null = null
export async function preloadCodeBlockRuntime() {
const runtime = await getUseMonaco()
return !!runtime
}
export async function getUseMonaco(): Promise<MonacoModule | null> {
if (loadingPromise)
return loadingPromise
loadingPromise = (async () => {
if (!mod) {
if (importFailed)
return null
try {
mod = await import('stream-monaco') as MonacoModule
}
catch {
importFailed = true
return null
}
}
try {
await preload(mod)
markCodeBlockRuntimeReady()
return mod
}
catch {
// Keep the imported module cached so temporary preload failures can retry.
return null
}
})()
try {
return await loadingPromise
}
finally {
loadingPromise = null
}
}
@@ -0,0 +1,92 @@
import type { CodeBlockMonacoTheme } from '../../types/component-props'
type SetTheme = (theme: CodeBlockMonacoTheme | undefined) => Promise<void> | void
let applyPromise: Promise<void> | null = null
let inFlightKey: string | null = null
let pendingTheme: CodeBlockMonacoTheme | null = null
let pendingKey: string | null = null
let lastAppliedKey: string | null = null
let currentSetTheme: SetTheme | null = null
const themeKeyCache = new WeakMap<object, string>()
let themeKeySeq = 0
function warnThemeSchedulerDev(error: unknown) {
if (import.meta.env?.DEV)
console.warn('[markstream-vue] Failed to apply Monaco theme:', error)
}
function getThemeKey(theme: CodeBlockMonacoTheme | null | undefined): string | null {
if (theme == null)
return null
if (typeof theme === 'string')
return theme
if (typeof theme === 'object' && 'name' in theme)
return String(theme.name)
if (typeof theme === 'object') {
const cached = themeKeyCache.get(theme)
if (cached)
return cached
try {
const str = JSON.stringify(theme)
if (str) {
themeKeyCache.set(theme, str)
return str
}
}
catch {}
const id = `__theme_${++themeKeySeq}`
themeKeyCache.set(theme, id)
return id
}
return String(theme)
}
export function scheduleGlobalMonacoTheme(setTheme: SetTheme, theme: CodeBlockMonacoTheme | null | undefined): Promise<void> {
const key = getThemeKey(theme)
if (!key)
return Promise.resolve()
currentSetTheme = setTheme
if (!applyPromise && lastAppliedKey === key)
return Promise.resolve()
if (applyPromise) {
if (pendingKey === key || inFlightKey === key)
return applyPromise
pendingTheme = theme
pendingKey = key
return applyPromise
}
pendingTheme = theme
pendingKey = key
applyPromise = (async () => {
while (pendingKey && pendingTheme != null) {
const nextTheme = pendingTheme
const nextKey = pendingKey
pendingTheme = null
pendingKey = null
if (lastAppliedKey === nextKey)
continue
try {
inFlightKey = nextKey
await (currentSetTheme ?? setTheme)(nextTheme)
lastAppliedKey = nextKey
}
catch (error) {
warnThemeSchedulerDev(error)
}
}
})().finally(() => {
applyPromise = null
inFlightKey = null
})
return applyPromise
}
@@ -0,0 +1,13 @@
let codeBlockRuntimeReady = false
export function isCodeBlockRuntimeReady() {
return codeBlockRuntimeReady
}
export function markCodeBlockRuntimeReady() {
codeBlockRuntimeReady = true
}
export function resetCodeBlockRuntimeReadyForTest() {
codeBlockRuntimeReady = false
}
@@ -0,0 +1,729 @@
<script setup lang="ts">
import type { D2BlockNodeProps } from '../../types/component-props'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSafeI18n } from '../../composables/useSafeI18n'
import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip'
import { useViewportPriority } from '../../composables/viewportPriority'
import { getD2 } from './d2'
const props = withDefaults(
defineProps<D2BlockNodeProps>(),
{
maxHeight: undefined,
loading: true,
progressiveRender: true,
progressiveIntervalMs: 700,
showHeader: true,
showModeToggle: true,
showCopyButton: true,
showExportButton: true,
showCollapseButton: true,
},
)
const { t } = useSafeI18n()
const copyText = ref(false)
const isCollapsed = ref(false)
const showSource = ref(false)
const d2Available = ref(false)
const renderError = ref<string | null>(null)
const isRendering = ref(false)
const svgMarkup = ref('')
const renderToken = ref(0)
const bodyRef = ref<HTMLElement | null>(null)
const bodyMinHeight = ref<number | null>(null)
const viewportTarget = ref<HTMLElement | null>(null)
const registerViewport = useViewportPriority()
const viewportHandle = ref<ReturnType<typeof registerViewport> | null>(null)
const viewportReady = ref(typeof window === 'undefined')
const baseCode = computed(() => props.node.code ?? '')
const renderSignature = computed(() => `${props.isDark ? 'dark' : 'light'}:${baseCode.value}`)
const showSourceFallback = computed(() => {
return showSource.value || !d2Available.value || !svgMarkup.value
})
const hasPreview = computed(() => !!svgMarkup.value)
const bodyStyle = computed(() => {
if (!showSourceFallback.value || !bodyMinHeight.value)
return undefined
return { minHeight: `${bodyMinHeight.value}px` }
})
const renderStyle = computed(() => {
if (props.maxHeight === 'none')
return { maxHeight: 'none' }
if (props.maxHeight != null) {
const max = typeof props.maxHeight === 'number' ? `${props.maxHeight}px` : String(props.maxHeight)
return { maxHeight: max }
}
// No explicit prop — CSS token handles it via scoped style
return undefined
})
const isClient = typeof window !== 'undefined'
let d2Instance: any | null = null
let scheduled = false
let unmounted = false
let lastRenderAt = 0
let throttleTimer: number | null = null
let pendingRender = false
let bodyObserver: ResizeObserver | null = null
let lastCompletedRenderSignature = ''
if (typeof window !== 'undefined') {
watch(
() => viewportTarget.value,
(el) => {
viewportHandle.value?.destroy()
viewportHandle.value = null
if (!el) {
viewportReady.value = false
return
}
const handle = registerViewport(el, { rootMargin: '160px' })
viewportHandle.value = handle
viewportReady.value = handle.isVisible.value
handle.whenVisible.then(() => {
viewportReady.value = true
})
},
{ immediate: true },
)
}
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',
}
// Tooltip helpers
function shouldSkipEventTarget(el: EventTarget | null) {
const btn = el as HTMLButtonElement | null
return !btn || btn.disabled
}
type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
function onBtnHover(e: Event, text: string, place: TooltipPlacement = 'top') {
if (shouldSkipEventTarget(e.currentTarget))
return
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(e.currentTarget as HTMLElement, text, place, false, origin, props.isDark)
}
function onBtnLeave() {
hideTooltip()
}
function onCopyHover(e: Event) {
if (shouldSkipEventTarget(e.currentTarget))
return
const txt = copyText.value ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(e.currentTarget as HTMLElement, txt, 'top', false, origin, props.isDark)
}
async function copy() {
try {
const text = baseCode.value
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text)
}
copyText.value = true
setTimeout(() => {
copyText.value = false
}, 1000)
}
catch (err) {
console.error('Copy failed:', err)
}
}
function toggleCollapse() {
isCollapsed.value = !isCollapsed.value
}
function handleSwitchMode(mode: 'preview' | 'source') {
showSource.value = mode === 'source'
}
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) {
const val = attr.value
if (DISALLOWED_STYLE_PATTERNS.some(re => re.test(val))) {
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)
}
}
}
}
function toSafeSvgMarkup(svg: string | null | undefined) {
if (typeof window === 'undefined' || 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)
svgElement.classList.add('markstream-d2-root-svg')
return svgElement.outerHTML
}
function setSvg(svg: string) {
const safe = toSafeSvgMarkup(svg)
svgMarkup.value = safe || ''
}
function clearSvg() {
svgMarkup.value = ''
}
function extractSvg(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 ''
}
async function ensureD2Instance() {
if (d2Instance)
return d2Instance
const D2Ctor = await getD2()
if (!D2Ctor)
return null
if (typeof D2Ctor === 'function') {
const inst = new D2Ctor()
if (inst && typeof inst.compile === 'function')
d2Instance = inst
else if (typeof (D2Ctor as any).compile === 'function')
d2Instance = D2Ctor
return d2Instance
}
if (D2Ctor?.D2 && typeof D2Ctor.D2 === 'function') {
d2Instance = new D2Ctor.D2()
return d2Instance
}
if (typeof D2Ctor.compile === 'function')
d2Instance = D2Ctor
return d2Instance
}
async function renderDiagram() {
if (!isClient || unmounted)
return
if (!viewportReady.value)
return
if (props.loading && !props.progressiveRender)
return
const signature = renderSignature.value
if (signature === lastCompletedRenderSignature && !renderError.value && svgMarkup.value) {
d2Available.value = true
if (props.loading)
showSource.value = false
return
}
const code = baseCode.value
if (!code) {
clearSvg()
renderError.value = null
lastCompletedRenderSignature = ''
return
}
const token = ++renderToken.value
isRendering.value = true
renderError.value = null
try {
const instance = await ensureD2Instance()
if (!instance) {
d2Available.value = false
showSource.value = true
clearSvg()
renderError.value = 'D2 is not available.'
return
}
if (typeof instance.compile !== 'function' || typeof instance.render !== 'function') {
throw new TypeError('D2 instance is missing compile/render methods.')
}
d2Available.value = true
const compileResult = await instance.compile(code)
if (token !== renderToken.value)
return
const diagram = compileResult?.diagram ?? compileResult
const baseRenderOptions = compileResult?.renderOptions ?? compileResult?.options ?? {}
const resolvedThemeId = props.themeId ?? baseRenderOptions.themeID
const resolvedDarkThemeId = props.darkThemeId ?? baseRenderOptions.darkThemeID
const renderOptions: Record<string, any> = { ...baseRenderOptions }
renderOptions.themeID = props.isDark && resolvedDarkThemeId != null
? resolvedDarkThemeId
: resolvedThemeId
// Ensure app-controlled theme takes precedence over OS-level preference.
renderOptions.darkThemeID = null
renderOptions.darkThemeOverrides = null
if (props.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 (token !== renderToken.value)
return
const svg = extractSvg(renderResult)
if (!svg)
throw new Error('D2 render returned empty output.')
setSvg(svg)
lastCompletedRenderSignature = signature
if (props.loading)
showSource.value = false
renderError.value = null
}
catch (err: any) {
if (token !== renderToken.value)
return
const message = err?.message ? String(err.message) : 'D2 render failed.'
if (!props.loading)
renderError.value = message
lastCompletedRenderSignature = ''
if (message.includes('@terrastruct/d2')) {
d2Available.value = false
showSource.value = true
}
}
finally {
if (token === renderToken.value) {
isRendering.value = false
if (pendingRender) {
pendingRender = false
scheduleRender()
}
}
}
}
function scheduleRender(force = false) {
if (scheduled)
return
if (!isClient)
return
if (unmounted)
return
if (isRendering.value) {
pendingRender = true
return
}
const interval = Math.max(120, Number(props.progressiveIntervalMs) || 0)
const now = Date.now()
const elapsed = now - lastRenderAt
if (!force && elapsed < interval) {
pendingRender = true
if (throttleTimer == null) {
throttleTimer = window.setTimeout(() => {
throttleTimer = null
if (pendingRender) {
pendingRender = false
scheduleRender(true)
}
}, Math.max(0, interval - elapsed))
}
return
}
scheduled = true
const runner = () => {
scheduled = false
lastRenderAt = Date.now()
renderDiagram()
}
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function')
window.requestAnimationFrame(runner)
else
setTimeout(runner, 0)
}
function exportSvg() {
if (!svgMarkup.value)
return
try {
const blob = new Blob([svgMarkup.value], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
if (typeof document !== 'undefined') {
const link = document.createElement('a')
link.href = url
link.download = `d2-diagram-${Date.now()}.svg`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
URL.revokeObjectURL(url)
}
catch (error) {
console.error('Failed to export SVG:', error)
}
}
function updateBodyMinHeight() {
const el = bodyRef.value
if (!el)
return
const height = el.getBoundingClientRect().height
if (height > 0)
bodyMinHeight.value = height
}
watch(
() => [props.node.code, props.loading, props.isDark],
() => {
scheduleRender()
},
{ immediate: true },
)
watch(
() => props.loading,
(loading, prev) => {
if (prev && !loading)
scheduleRender(true)
},
)
watch(
() => viewportReady.value,
(ready) => {
if (ready)
scheduleRender(true)
},
)
watch(
() => [showSourceFallback.value, svgMarkup.value, baseCode.value],
() => {
nextTick(() => {
updateBodyMinHeight()
})
},
)
onMounted(() => {
nextTick(() => {
updateBodyMinHeight()
})
if (typeof ResizeObserver !== 'undefined') {
bodyObserver = new ResizeObserver(() => {
updateBodyMinHeight()
})
if (bodyRef.value)
bodyObserver.observe(bodyRef.value)
}
})
onBeforeUnmount(() => {
unmounted = true
lastCompletedRenderSignature = ''
viewportHandle.value?.destroy()
viewportHandle.value = null
if (throttleTimer != null) {
clearTimeout(throttleTimer)
throttleTimer = null
}
bodyObserver?.disconnect()
bodyObserver = null
})
</script>
<template>
<div
ref="viewportTarget"
class="d2-block-container rounded-lg border overflow-hidden"
data-markstream-d2="1"
:data-markstream-mode="showSourceFallback ? 'fallback' : 'preview'"
:class="{ dark: props.isDark }"
>
<div
v-if="props.showHeader"
class="d2-block-header flex justify-between items-center border-b"
>
<div class="flex items-center gap-x-2">
<span class="d2-label font-medium font-mono">D2</span>
</div>
<div class="d2-header-actions flex items-center">
<div
v-if="props.showModeToggle"
class="d2-mode-toggle flex items-center gap-0.5"
>
<button
type="button"
class="mode-btn px-2 py-0.5 rounded"
:class="!showSource ? 'is-active' : ''"
@click="handleSwitchMode('preview')"
@mouseenter="onBtnHover($event, t('common.preview') || 'Preview')"
@focus="onBtnHover($event, t('common.preview') || 'Preview')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
{{ t('common.preview') || 'Preview' }}
</button>
<button
type="button"
class="mode-btn px-2 py-0.5 rounded"
:class="showSource ? 'is-active' : ''"
@click="handleSwitchMode('source')"
@mouseenter="onBtnHover($event, t('common.source') || 'Source')"
@focus="onBtnHover($event, t('common.source') || 'Source')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
{{ t('common.source') || 'Source' }}
</button>
</div>
<button
v-if="props.showCopyButton"
type="button"
class="d2-action-btn p-[var(--ms-action-btn-padding)] rounded-md"
:aria-label="copyText ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')"
@click="copy"
@mouseenter="onCopyHover($event)"
@focus="onCopyHover($event)"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg v-if="!copyText" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-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 v-else xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5" /></svg>
</button>
<button
v-if="props.showExportButton && svgMarkup"
type="button"
class="d2-action-btn p-[var(--ms-action-btn-padding)] rounded-md"
:aria-label="t('common.export') || 'Export'"
@click="exportSvg"
@mouseenter="onBtnHover($event, t('common.export') || 'Export')"
@focus="onBtnHover($event, t('common.export') || 'Export')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v12m0-12l-4 4m4-4l4 4M4 14v4a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4" /></svg>
</button>
<button
v-if="props.showCollapseButton"
type="button"
class="d2-action-btn p-[var(--ms-action-btn-padding)] rounded-md"
:aria-pressed="isCollapsed"
@click="toggleCollapse"
@mouseenter="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@focus="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg :style="{ rotate: isCollapsed ? '0deg' : '90deg' }" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 18l6-6l-6-6" /></svg>
</button>
</div>
</div>
<div v-show="!isCollapsed" ref="bodyRef" class="d2-block-body" :style="bodyStyle">
<div v-if="props.loading && !hasPreview" class="d2-source">
<pre class="d2-code"><code>{{ baseCode }}</code></pre>
<p v-if="renderError" class="d2-error mt-2 text-xs">
{{ renderError }}
</p>
</div>
<div v-else>
<div v-if="showSourceFallback" class="d2-source">
<pre class="d2-code"><code>{{ baseCode }}</code></pre>
<p v-if="renderError" class="d2-error mt-2 text-xs">
{{ renderError }}
</p>
</div>
<div v-else class="d2-render" :style="renderStyle">
<div class="d2-svg" v-html="svgMarkup" />
<p v-if="renderError" class="d2-error px-4 pb-3 text-xs">
{{ renderError }}
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Container ── */
.d2-block-container {
margin: var(--ms-flow-diagram-y) 0;
background: var(--diagram-bg);
border-color: var(--diagram-border);
color: hsl(var(--ms-foreground));
box-shadow: var(--ms-shadow-subtle);
}
/* ── Header ── */
.d2-block-header {
padding: var(--ms-inset-panel-y) var(--ms-inset-panel-x);
background: var(--diagram-header-bg);
border-color: var(--diagram-border);
color: hsl(var(--ms-foreground));
}
/* ── Mode toggle ── */
.d2-mode-toggle {
background: transparent;
}
.mode-btn {
font-size: var(--ms-text-label);
color: var(--code-action-fg);
opacity: 0.6;
transition: opacity 0.2s, color 0.2s, background-color 0.2s;
}
.mode-btn:hover {
opacity: 0.9;
}
.mode-btn.is-active {
background: hsl(var(--ms-foreground) / 0.08);
color: var(--code-fg);
opacity: 1;
}
.d2-header-actions {
gap: var(--ms-gap-header-actions);
}
/* ── Action buttons ── */
.d2-action-btn {
color: var(--code-action-fg);
opacity: 0.7;
transition: opacity 0.2s, background-color 0.15s, color 0.15s;
}
.d2-action-btn:hover {
opacity: 1;
background: var(--code-action-hover-bg);
color: var(--code-action-hover-fg);
}
.d2-action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* ── Body ── */
.d2-block-body {
position: relative;
}
.d2-source {
padding: var(--ms-inset-panel-body) var(--ms-inset-panel-x);
font-family: var(--vscode-editor-font-family, 'Fira Code', 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
}
.d2-code {
white-space: pre;
font-size: 0.875rem;
line-height: 1.5;
}
.d2-render {
max-height: var(--ms-size-code-max-height);
overflow: auto;
}
.d2-svg :deep(svg.markstream-d2-root-svg) {
width: 100%;
max-width: 100%;
height: auto;
display: block;
}
.d2-label {
font-size: var(--ms-text-label);
}
.action-icon {
width: var(--ms-action-btn-icon);
height: var(--ms-action-btn-icon);
}
.d2-error {
color: hsl(var(--ms-destructive));
}
</style>
@@ -0,0 +1,67 @@
export type D2Loader = () => Promise<any> | any
const defaultD2Loader: D2Loader = () => import('@terrastruct/d2')
let cachedD2: any = null
let d2Loader: D2Loader | null = defaultD2Loader
function resetCachedD2() {
cachedD2 = 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
const loader = d2Loader
if (!loader)
return null
let mod: any
try {
mod = await loader()
}
catch (err) {
if (loader === defaultD2Loader) {
throw new Error('Optional dependency "@terrastruct/d2" is not installed. Please install it to enable D2 diagrams.')
}
throw err
}
if (!mod)
return null
cachedD2 = normalizeD2Module(mod)
return cachedD2
}
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import D2BlockNode from './D2BlockNode.vue'
D2BlockNode.install = (app: App) => {
app.component(D2BlockNode.__name as string, D2BlockNode)
}
export default D2BlockNode
@@ -0,0 +1,77 @@
<script setup lang="ts">
import NodeRenderer from '../NodeRenderer'
// 定义定义项节点
interface DefinitionItemNode {
type: 'definition_item'
term: { type: string, raw: string }[]
definition: { type: string, raw: string }[]
raw: string
}
// 定义定义列表节点
interface DefinitionListNode {
type: 'definition_list'
items: DefinitionItemNode[]
raw: string
}
// 接收props
const props = defineProps<{
node: DefinitionListNode
indexKey: string | number
typewriter?: boolean
customId?: string
}>()
// 定义事件
defineEmits(['copy'])
</script>
<template>
<dl class="definition-list">
<template v-for="(item, index) in props.node.items" :key="index">
<dt class="definition-term">
<NodeRenderer
:index-key="`definition-term-${props.indexKey}-${index}`"
:nodes="item.term"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
</dt>
<dd class="definition-desc">
<NodeRenderer
:index-key="`definition-desc-${props.indexKey}-${index}`"
:nodes="item.definition"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
</dd>
</template>
</dl>
</template>
<style scoped>
.definition-list {
margin: 0 0 1rem;
}
.definition-term {
font-weight: 600;
margin-top: var(--ms-flow-definition-term-mt);
}
.definition-desc {
margin-left: var(--ms-flow-definition-desc-ml);
margin-bottom: var(--ms-flow-definition-desc-mb);
}
/* 避免列表中嵌套 NodeRenderer 的 content-visibility 导致空白占位 */
.definition-list :deep(.markdown-renderer) {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0px 0px;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import DefinitionListNode from './DefinitionListNode.vue'
DefinitionListNode.install = (app: App) => {
app.component(DefinitionListNode.__name as string, DefinitionListNode)
}
export default DefinitionListNode
@@ -0,0 +1,20 @@
<script setup lang="ts">
defineProps<{
node: {
type: 'emoji'
name: string
markup: string
raw: string
}
}>()
</script>
<template>
<span class="emoji-node">{{ node.name }}</span>
</template>
<style scoped>
.emoji-node {
display: inline-block;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import EmojiNode from './EmojiNode.vue'
EmojiNode.install = (app: App) => {
app.component(EmojiNode.__name as string, EmojiNode)
}
export default EmojiNode
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'emphasis'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
footnote_reference: FootnoteReferenceNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<em class="emphasis-node">
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'emphasis'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'emphasis'}-${index}`"
/>
</em>
</template>
<style scoped>
.emphasis-node {
font-style: italic;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import EmphasisNode from './EmphasisNode.vue'
EmphasisNode.install = (app: App) => {
app.component(EmphasisNode.__name as string, EmphasisNode)
}
export default EmphasisNode
@@ -0,0 +1,34 @@
<script setup lang="ts">
const props = defineProps<{ node: { type: 'footnote_anchor', id: string, raw?: string } }>()
function scrollToReference(e) {
e.preventDefault()
if (typeof document === 'undefined')
return
const id = `fnref-${String(props.node.id ?? '')}`
// Try to find the reference element rendered by FootnoteReferenceNode.
// FootnoteReferenceNode renders a span. We search for .footnote-link text matching [id].
const anchors = document.getElementById(id)
if (anchors) {
anchors.scrollIntoView({ behavior: 'smooth' })
}
}
</script>
<template>
<a
class="footnote-anchor text-sm hover:underline cursor-pointer"
:href="`#fnref-${node.id}`"
:title="`返回引用 ${node.id}`"
@click="scrollToReference"
>
↩︎
</a>
</template>
<style scoped>
.footnote-anchor {
margin-left: 0.5rem;
color: var(--link-color);
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import FootnoteAnchorNode from './FootnoteAnchorNode.vue'
FootnoteAnchorNode.install = (app: App) => {
app.component(FootnoteAnchorNode.__name as string, FootnoteAnchorNode)
}
export default FootnoteAnchorNode
@@ -0,0 +1,55 @@
<script setup lang="ts">
import NodeRenderer from '../NodeRenderer'
// 定义脚注节点
interface FootnoteNode {
type: 'footnote'
id: string
children: { type: string, raw: string }[]
raw: string
}
// 接收props
const props = defineProps<{
node: FootnoteNode
indexKey: string | number
typewriter?: boolean
customId?: string
}>()
// 定义事件
defineEmits(['copy'])
</script>
<template>
<div
:id="`fnref--${node.id}`"
class="footnote-node flex text-sm leading-relaxed border-t border-[var(--footnote-border)] pt-2"
>
<!-- <span class="font-semibold mr-2 text-[#0366d6]">[{{ node.id }}]</span> -->
<div class="flex-1">
<NodeRenderer
:index-key="`footnote-${props.indexKey}`"
:nodes="props.node.children"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
</div>
</div>
</template>
<style>
.footnote-node {
margin-top: var(--ms-flow-footnote-y);
margin-bottom: var(--ms-flow-footnote-y);
}
/* 脚注中嵌套 NodeRenderer 关闭 content-visibility 占位,防止空白内容 */
.markstream-vue [class*="footnote-"] .markdown-renderer,
.markstream-vue .flex-1 .markdown-renderer {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0px 0px;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import FootnoteNode from './FootnoteNode.vue'
FootnoteNode.install = (app: App) => {
app.component(FootnoteNode.__name as string, FootnoteNode)
}
export default FootnoteNode
@@ -0,0 +1,53 @@
<script setup lang="ts">
// 定义脚注引用节点
interface FootnoteReferenceNode {
type: 'footnote_reference'
id: string
raw: string
}
// 接收props
const props = defineProps<{
node: FootnoteReferenceNode
}>()
const href = `#footnote-${props.node.id}`
function handleScroll() {
if (typeof document === 'undefined') {
// SSR: nothing to do
return
}
const element = document.querySelector(href)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
else {
console.warn(`Element with href: ${href} not found`)
}
}
</script>
<template>
<sup :id="`fnref-${node.id}`" class="footnote-reference" @click="handleScroll">
<span
:href="href"
:title="`查看脚注 ${node.id}`"
class="footnote-link cursor-pointer"
>[{{ node.id }}]</span>
</sup>
</template>
<style scoped>
.footnote-reference {
font-size: 0.75em;
line-height: 0;
}
.footnote-link {
color: var(--link-color);
text-decoration: none;
}
.footnote-link:hover {
text-decoration: underline;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import FootnoteReferenceNode from './FootnoteReferenceNode.vue'
FootnoteReferenceNode.install = (app: App) => {
app.component(FootnoteReferenceNode.__name as string, FootnoteReferenceNode)
}
export default FootnoteReferenceNode
@@ -0,0 +1,22 @@
<script setup lang="ts">
// 定义硬换行节点
interface HardBreakNode {
type: 'hardbreak'
raw: string
}
// 接收props
defineProps<{
node: HardBreakNode
}>()
</script>
<template>
<br class="hard-break">
</template>
<style scoped>
.hard-break {
display: block;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import HardBreakNode from './HardBreakNode.vue'
HardBreakNode.install = (app: App) => {
app.component(HardBreakNode.__name as string, HardBreakNode)
}
export default HardBreakNode
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import CheckboxNode from '../CheckboxNode'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HardBreakNode from '../HardBreakNode'
import HighlightNode from '../HighlightNode'
import ImageNode from '../ImageNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
// Define the type for the node children
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'heading'
level: number
text: string
attrs?: Record<string, string | boolean>
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
image: ImageNode,
strong: StrongNode,
emphasis: EmphasisNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
checkbox: CheckboxNode,
checkbox_input: CheckboxNode,
footnote_reference: FootnoteReferenceNode,
hardbreak: HardBreakNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<component
:is="`h${node.level}`"
class="heading-node"
:class="[`heading-${node.level}`]"
dir="auto"
v-bind="node.attrs"
>
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'heading'}-${index}`"
:custom-id="props.customId"
:node="child"
:index-key="`${indexKey || 'heading'}-${index}`"
/>
</component>
</template>
<style scoped>
.heading-node {
font-weight: 500;
line-height: 1.25;
}
hr + .heading-node {
margin-top: 0;
}
.heading-1 {
font-size: var(--ms-text-h1);
line-height: var(--ms-leading-h1);
font-weight: var(--ms-weight-h1);
margin-top: var(--ms-flow-heading-1-mt);
margin-bottom: var(--ms-flow-heading-1-mb);
}
.heading-2 {
font-size: var(--ms-text-h2);
line-height: var(--ms-leading-h2);
font-weight: var(--ms-weight-h2);
margin-top: var(--ms-flow-heading-2-mt);
margin-bottom: var(--ms-flow-heading-2-mb);
}
.heading-3 {
font-size: var(--ms-text-h3);
line-height: var(--ms-leading-h3);
font-weight: var(--ms-weight-h3);
margin-top: var(--ms-flow-heading-3-mt);
margin-bottom: var(--ms-flow-heading-3-mb);
}
.heading-4 {
font-size: var(--ms-text-h4);
font-weight: var(--ms-weight-h4);
margin-top: var(--ms-flow-heading-4-mt);
margin-bottom: var(--ms-flow-heading-4-mb);
}
.heading-5 {
font-size: var(--ms-text-h5);
margin-top: var(--ms-flow-heading-5-mt);
margin-bottom: var(--ms-flow-heading-5-mb);
}
.heading-6 {
font-size: var(--ms-text-h6);
margin-top: var(--ms-flow-heading-6-mt);
margin-bottom: var(--ms-flow-heading-6-mb);
}
</style>
@@ -0,0 +1,11 @@
import type { App } from 'vue'
import HeadingNode from './HeadingNode.vue'
// local binding to avoid circular import alias during declaration emit
const _HeadingNode = HeadingNode as any
_HeadingNode.install = (app: App) => {
app.component(HeadingNode.__name as string, HeadingNode)
}
export default _HeadingNode
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'highlight'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
emphasis: EmphasisNode,
strikethrough: StrikethroughNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
footnote_reference: FootnoteReferenceNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<mark class="highlight-node">
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'highlight'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'highlight'}-${index}`"
/>
</mark>
</template>
<style scoped>
.highlight-node {
background-color: var(--highlight-bg);
padding: 0 0.2rem;
border-radius: 0.2em;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import HighlightNode from './HighlightNode.vue'
HighlightNode.install = (app: App) => {
app.component(HighlightNode.__name as string, HighlightNode)
}
export default HighlightNode
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { NON_STRUCTURING_HTML_TAGS, sanitizeHtmlContent, sanitizeHtmlTokenAttrs, tokenAttrsToRecord } from 'stream-markdown-parser'
import { computed, defineAsyncComponent, defineComponent, onBeforeUnmount, ref, watch } from 'vue'
import { useViewportPriority } from '../../composables/viewportPriority'
import { hasCustomComponents, parseHtmlToVNodes } from '../../utils/htmlRenderer'
import { customComponentsRevision, getCustomNodeComponents } from '../../utils/nodeComponents'
const props = defineProps<{
node: {
content: string
raw?: string
tag?: string
attrs?: [string, string][] | null
children?: any[]
loading?: boolean
}
customId?: string
}>()
const StructuredNodeRenderer = defineAsyncComponent({
loader: () => import('../NodeRenderer'),
suspensible: false,
})
const boundAttrs = computed(() => {
const sanitizedAttrs = sanitizeHtmlTokenAttrs(props.node.attrs)
if (!sanitizedAttrs)
return undefined
const record = tokenAttrsToRecord(sanitizedAttrs)
return Object.keys(record).length > 0 ? record : undefined
})
// Get custom components from global registry
const customComponents = computed(() => {
// Track revision so we re-parse when mappings change.
void customComponentsRevision.value
return getCustomNodeComponents(props.customId)
})
// Dynamic wrapper component for rendering VNodes
const DynamicRenderer = defineComponent({
name: 'DynamicRenderer',
props: {
nodes: {
type: Array as () => any[],
required: true,
},
},
render() {
return this.nodes
},
})
const htmlRef = ref<HTMLElement | null>(null)
const shouldRender = ref(typeof window === 'undefined')
const renderContent = ref(props.node.content)
const structuredChildren = computed(() => Array.isArray(props.node.children) ? props.node.children : [])
const structuredTag = computed(() => String(props.node.tag || 'div'))
const isBlockedStructuredTag = computed(() => NON_STRUCTURING_HTML_TAGS.has(structuredTag.value.trim().toLowerCase()))
const isStructured = computed(() => structuredChildren.value.length > 0 && !!props.node.tag && !isBlockedStructuredTag.value)
// Computed property to determine render mode and content
const renderMode = computed(() => {
if (isStructured.value)
return { mode: 'structured' as const }
// Avoid parsing until the node is actually going to render (deferred rendering path).
if (!shouldRender.value)
return { mode: 'html', content: renderContent.value ?? '' }
const content = renderContent.value ?? props.node.content
if (!content)
return { mode: 'html', content: '' }
// Streaming HTML blocks are expensive to re-render via `innerHTML` because it
// replaces the whole subtree on every tick. Prefer the VNode parser while
// the node is still in a loading mid-state to keep DOM stable.
if (props.node.loading) {
const nodes = parseHtmlToVNodes(content, customComponents.value)
if (nodes === null)
return { mode: 'text', content: props.node.raw ?? content }
return { mode: 'dynamic', nodes }
}
// Check if content contains custom components
if (!hasCustomComponents(content, customComponents.value))
return { mode: 'html', content: sanitizeHtmlContent(content) }
// Parse and build VNode tree
const nodes = parseHtmlToVNodes(content, customComponents.value)
if (nodes === null)
return { mode: 'html', content: sanitizeHtmlContent(content) } // Fallback to sanitized HTML if parsing fails
return { mode: 'dynamic', nodes }
})
const registerVisibility = useViewportPriority()
const visibilityHandle = ref<ReturnType<typeof registerVisibility> | null>(null)
const isDeferred = !!props.node.loading
if (typeof window !== 'undefined') {
watch(
htmlRef,
(el) => {
visibilityHandle.value?.destroy?.()
visibilityHandle.value = null
if (!isDeferred) {
shouldRender.value = true
renderContent.value = props.node.content
return
}
if (!el) {
shouldRender.value = false
return
}
const handle = registerVisibility(el, { rootMargin: '400px' })
visibilityHandle.value = handle
shouldRender.value = handle.isVisible.value
handle.whenVisible.then(() => {
shouldRender.value = true
})
},
{ immediate: true },
)
watch(
() => props.node.content,
(val) => {
if (!isDeferred || shouldRender.value) {
renderContent.value = val
}
},
)
}
else {
shouldRender.value = true
}
onBeforeUnmount(() => {
visibilityHandle.value?.destroy?.()
visibilityHandle.value = null
})
</script>
<template>
<component
:is="isStructured ? structuredTag : 'div'"
ref="htmlRef"
class="html-block-node"
v-bind="isStructured ? boundAttrs : undefined"
>
<template v-if="shouldRender">
<StructuredNodeRenderer
v-if="renderMode.mode === 'structured'"
:nodes="structuredChildren"
:custom-id="customId"
:batch-rendering="false"
:defer-nodes-until-visible="false"
:render-as-fragment="true"
/>
<!-- Use dynamic rendering for custom components -->
<DynamicRenderer v-else-if="renderMode.mode === 'dynamic'" :nodes="renderMode.nodes" />
<pre v-else-if="renderMode.mode === 'text'" class="html-block-node__raw">{{ renderMode.content }}</pre>
<!-- Fallback to v-html for standard HTML -->
<div v-else v-bind="boundAttrs" v-html="renderMode.content" />
</template>
<div v-else class="html-block-node__placeholder">
<slot name="placeholder" :node="node">
<span class="html-block-node__placeholder-bar" />
<span class="html-block-node__placeholder-bar w-4/5" />
<span class="html-block-node__placeholder-bar w-2/3" />
</slot>
</div>
</component>
</template>
<style scoped>
.html-block-node__raw {
white-space: pre-wrap;
overflow-wrap: anywhere;
opacity: 0.85;
}
.html-block-node__placeholder {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.5rem 0;
}
.html-block-node__placeholder-bar {
display: block;
height: 0.8rem;
border-radius: 9999px;
background-image: linear-gradient(90deg, var(--loading-shimmer), transparent, var(--loading-shimmer));
background-size: 200% 100%;
animation: html-block-node-shimmer 1.2s ease infinite;
}
@keyframes html-block-node-shimmer {
0% {
background-position: 0% 0%;
}
100% {
background-position: 200% 0%;
}
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import HtmlBlockNode from './HtmlBlockNode.vue'
HtmlBlockNode.install = (app: App) => {
app.component(HtmlBlockNode.__name as string, HtmlBlockNode)
}
export default HtmlBlockNode
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { sanitizeHtmlContent } from 'stream-markdown-parser'
import { computed, defineComponent } from 'vue'
import { hasCustomComponents, parseHtmlToVNodes } from '../../utils/htmlRenderer'
import { customComponentsRevision, getCustomNodeComponents } from '../../utils/nodeComponents'
const props = defineProps<{
node: {
type: 'html_inline'
tag?: string
content: string
loading?: boolean
autoClosed?: boolean
}
customId?: string
}>()
// Get custom components from global registry
const customComponents = computed(() => {
// Track revision so we re-parse when mappings change.
void customComponentsRevision.value
return getCustomNodeComponents(props.customId)
})
// Dynamic wrapper component for rendering VNodes
const DynamicRenderer = defineComponent({
name: 'DynamicRenderer',
props: {
nodes: {
type: Array as () => any[],
required: true,
},
},
render() {
return this.nodes
},
})
// Computed property to determine render mode and content
const renderMode = computed(() => {
const content = props.node.content
if (!content)
return { mode: 'html', content: '' }
if (props.node.loading && !props.node.autoClosed)
return { mode: 'text', content }
// When the inline HTML node is in a streaming mid-state and the parser has
// auto-closed it for rendering (`autoClosed: true`), prefer VNode rendering.
// Using `innerHTML` repeatedly replaces the subtree and can cause flicker.
if (props.node.loading && props.node.autoClosed) {
const nodes = parseHtmlToVNodes(content, customComponents.value)
if (nodes !== null)
return { mode: 'dynamic', nodes }
}
// Check if content contains custom components
if (!hasCustomComponents(content, customComponents.value))
return { mode: 'html', content: sanitizeHtmlContent(content) }
// Parse and build VNode tree
const nodes = parseHtmlToVNodes(content, customComponents.value)
if (nodes === null)
return { mode: 'html', content: sanitizeHtmlContent(content) } // Fallback to sanitized DOM rendering if parsing fails
return { mode: 'dynamic', nodes }
})
</script>
<template>
<span
v-if="renderMode.mode === 'dynamic'"
class="html-inline-node"
:class="{ 'html-inline-node--loading': props.node.loading }"
>
<DynamicRenderer :nodes="renderMode.nodes" />
</span>
<span
v-else-if="renderMode.mode === 'text'"
class="html-inline-node"
:class="{ 'html-inline-node--loading': props.node.loading }"
>{{ renderMode.content }}</span>
<span
v-else
class="html-inline-node"
:class="{ 'html-inline-node--loading': props.node.loading }"
v-html="renderMode.content"
/>
</template>
<style scoped>
.html-inline-node {
display: inline;
}
.html-inline-node--loading {
opacity: 0.85;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import HtmlInlineNode from './HtmlInlineNode.vue'
HtmlInlineNode.install = (app: App) => {
app.component(HtmlInlineNode.__name as string, HtmlInlineNode)
}
export default HtmlInlineNode
@@ -0,0 +1,213 @@
<script setup lang="ts">
// 定义图片节点类型
import type { ImageNodeProps } from '../../types/component-props'
import { computed, ref, watch } from 'vue'
import { useSafeI18n } from '../../composables/useSafeI18n'
// 接收 props:node 是必须,其他为可选配置(fallback、是否启用 lazy)
const props = withDefaults(defineProps<ImageNodeProps>(), {
fallbackSrc: '',
lazy: false,
usePlaceholder: true,
})
// 事件:load / error / click(click 用于外部处理图片预览)
const emit = defineEmits<{ (e: 'load', src: string): void, (e: 'error', src: string): void, (e: 'click', payload: [Event, string]): void }>()
// 图片加载状态
const imageLoaded = ref(false)
const hasError = ref(false)
const fallbackTried = ref(false)
// 计算当前用于渲染的 src(当有 error 且提供 fallback 时使用 fallback)
const displaySrc = computed(() => hasError.value && props.fallbackSrc ? props.fallbackSrc : props.node.src)
const useEagerImagePath = computed(() => !props.lazy)
// 处理图片加载错误:尝试一次 fallback,否则保留错误状态
function handleImageError() {
if (props.fallbackSrc && !fallbackTried.value) {
fallbackTried.value = true
hasError.value = true
}
else {
hasError.value = true
emit('error', props.node.src)
}
}
// 处理图片加载完成
function handleImageLoad() {
imageLoaded.value = true
hasError.value = false
emit('load', displaySrc.value)
}
// 当用户点击/触摸图片时(仅对已成功加载的图片有效),向外发出 click 事件(用于图片 preview)
function handleClick(e: Event) {
e.preventDefault()
if (!imageLoaded.value || hasError.value)
return
emit('click', [e, displaySrc.value])
}
const { t } = useSafeI18n()
// When the src changes (displaySrc), reset imageLoaded so the new image can fade in
watch(displaySrc, () => {
imageLoaded.value = false
hasError.value = false
})
</script>
<template>
<span class="image-node-container">
<transition name="img-switch" mode="out-in">
<!-- Loaded image -->
<img
v-if="!node.loading && !hasError"
key="image"
:src="displaySrc"
:alt="String(props.node.alt ?? props.node.title ?? '')"
:title="String(props.node.title ?? props.node.alt ?? '')"
class="image-node__img"
:class="{
'is-loading': !useEagerImagePath && !imageLoaded,
'is-loaded': useEagerImagePath || imageLoaded,
'cursor-pointer': imageLoaded,
}"
:loading="props.lazy ? 'lazy' : undefined"
:fetchpriority="useEagerImagePath ? 'high' : undefined"
:decoding="useEagerImagePath ? 'sync' : 'async'"
:tabindex="imageLoaded ? 0 : -1"
:aria-label="props.node.alt ?? t('image.preview')"
@error="handleImageError"
@load="handleImageLoad"
@click="handleClick"
>
<!-- Loading placeholder — shimmer skeleton -->
<span
v-else-if="!hasError"
key="placeholder"
class="image-placeholder"
>
<template v-if="props.usePlaceholder">
<slot name="placeholder" :node="props.node" :display-src="displaySrc" :image-loaded="imageLoaded" :has-error="hasError" :fallback-src="props.fallbackSrc" :lazy="props.lazy">
<span class="image-shimmer" />
</slot>
</template>
<template v-else>
<span class="image-node__raw-text">{{ node.raw }}</span>
</template>
</span>
<!-- Error state -->
<span v-else-if="!node.loading && !props.fallbackSrc" key="error" class="image-error">
<slot name="error" :node="props.node" :display-src="displaySrc" :image-loaded="imageLoaded" :has-error="hasError" :fallback-src="props.fallbackSrc" :lazy="props.lazy">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M2 2h20v10h-2V4H4v9.586l5-5L14.414 14L13 15.414l-4-4l-5 5V20h8v2H2zm13.547 5a1 1 0 1 0 0 2a1 1 0 0 0 0-2m-3 1a3 3 0 1 1 6 0a3 3 0 0 1-6 0m3.625 6.757L19 17.586l2.828-2.829l1.415 1.415L20.414 19l2.829 2.828l-1.415 1.415L19 20.414l-2.828 2.829l-1.415-1.415L17.586 19l-2.829-2.828z" /></svg>
<span>{{ t('image.loadError') }}</span>
</slot>
</span>
</transition>
</span>
</template>
<style scoped>
/* ── Container ── */
.image-node-container {
display: inline-block;
vertical-align: middle;
max-width: var(--ms-size-image-max-width);
}
/* ── Image ── */
.image-node__img {
display: inline-block;
max-width: 100%;
height: auto;
vertical-align: middle;
transition: opacity var(--ms-duration-emphasis) var(--ms-ease-standard);
}
.image-node__img.is-loading {
opacity: 0;
}
.image-node__img.is-loaded {
opacity: 1;
}
/* ── Placeholder — shimmer skeleton ── */
.image-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 8rem;
max-width: var(--ms-size-image-max-width);
background: hsl(var(--ms-muted));
overflow: hidden;
vertical-align: middle;
}
.image-shimmer {
display: block;
width: 100%;
height: 100%;
min-height: 8rem;
background: linear-gradient(
90deg,
hsl(var(--ms-muted)) 0%,
hsl(var(--ms-muted-foreground) / 0.06) 50%,
hsl(var(--ms-muted)) 100%
);
background-size: 200% 100%;
animation: image-shimmer 1.5s ease-in-out infinite;
}
@keyframes image-shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── Error ── */
.image-error {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem 1.5rem;
min-height: 4rem;
max-width: var(--ms-size-image-max-width);
background: hsl(var(--ms-muted));
color: hsl(var(--ms-muted-foreground));
font-size: var(--ms-text-label);
vertical-align: middle;
}
/* ── Raw text fallback ── */
.image-node__raw-text {
font-size: var(--ms-text-label);
color: hsl(var(--ms-muted-foreground));
}
/* ── Transition ── */
.img-switch-enter-active, .img-switch-leave-active {
transition: opacity var(--ms-duration-emphasis) var(--ms-ease-standard),
transform var(--ms-duration-emphasis) var(--ms-ease-standard);
}
.img-switch-enter-from, .img-switch-leave-to {
opacity: 0;
transform: translateY(4px);
}
.img-switch-enter-to, .img-switch-leave-from {
opacity: 1;
transform: translateY(0);
}
/* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.image-shimmer { animation: none !important; }
.img-switch-enter-active, .img-switch-leave-active { transition: none !important; }
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import ImageNode from './ImageNode.vue'
ImageNode.install = (app: App) => {
app.component(ImageNode.__name as string, ImageNode)
}
export default ImageNode
@@ -0,0 +1,924 @@
<script setup lang="ts">
import type { InfographicBlockNodeProps } from '../../types/component-props'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSafeI18n } from '../../composables/useSafeI18n'
import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip'
import { useViewportPriority } from '../../composables/viewportPriority'
import infographicIcon from '../../icon/infographic.svg?raw'
import { clampInfographicPreviewHeight, estimateInfographicPreviewHeight, parsePositiveNumber } from '../../utils/diagramHeight'
import { getInfographic, isInfographicEnabled } from './infographic'
const props = withDefaults(
defineProps<InfographicBlockNodeProps>(),
{
maxHeight: undefined,
loading: true,
showHeader: true,
showCopyButton: true,
showCollapseButton: true,
showModeToggle: true,
showExportButton: true,
showFullscreenButton: true,
showZoomControls: true,
},
)
const _emits = defineEmits(['copy', 'export', 'openModal'])
const { t } = useSafeI18n()
const registerViewport = useViewportPriority()
const copyText = ref(false)
const isCollapsed = ref(false)
const viewportTarget = ref<HTMLElement>()
const infographicContainer = ref<HTMLElement>()
const infographicInitiallyEnabled = typeof window !== 'undefined' && isInfographicEnabled()
const showSource = ref(!infographicInitiallyEnabled)
const userToggledShowSource = ref(false)
const isModalOpen = ref(false)
const modalContent = ref<HTMLElement>()
const modalCloneWrapper = ref<HTMLElement | null>(null)
const hasPreview = ref(false)
const viewportHandle = ref<ReturnType<typeof registerViewport> | null>(null)
const viewportReady = ref(typeof window === 'undefined')
if (typeof window !== 'undefined') {
watch(
() => viewportTarget.value,
(el) => {
viewportHandle.value?.destroy()
viewportHandle.value = null
if (!el) {
viewportReady.value = false
return
}
const handle = registerViewport(el, { rootMargin: '160px' })
viewportHandle.value = handle
viewportReady.value = handle.isVisible.value
handle.whenVisible.then(() => {
viewportReady.value = true
})
},
{ immediate: true },
)
}
function resolveContainerHeight(actualHeight: number) {
if (props.maxHeight === 'none')
return `${actualHeight}px`
// Explicit prop value takes priority
if (props.maxHeight != null) {
const maxHeight = Number.parseFloat(String(props.maxHeight))
if (Number.isFinite(maxHeight))
return `${Math.min(actualHeight, maxHeight)}px`
}
// Fall back to CSS token (respects density theming)
const el = infographicContainer.value
if (el) {
const raw = getComputedStyle(el).getPropertyValue('--ms-size-code-max-height').trim()
const num = Number.parseFloat(raw)
if (Number.isFinite(num))
return `${Math.min(actualHeight, num)}px`
}
return `${Math.min(actualHeight, 500)}px` // ultimate fallback
}
function clampPreviewHeight(height: number) {
if (props.maxHeight === 'none')
return clampInfographicPreviewHeight(height, undefined, null)
const explicitMax = parsePositiveNumber(props.maxHeight)
return clampInfographicPreviewHeight(height, undefined, explicitMax)
}
const baseCode = computed(() => props.node.code)
const estimatedPreviewHeight = computed(() =>
clampPreviewHeight(
parsePositiveNumber(props.estimatedPreviewHeightPx) ?? estimateInfographicPreviewHeight(baseCode.value),
),
)
const containerHeight = ref<string>(`${estimatedPreviewHeight.value}px`)
function updateContainerHeight() {
if (!infographicContainer.value)
return
const actualHeight = infographicContainer.value.scrollHeight
if (actualHeight > 0) {
const resolvedHeight = parsePositiveNumber(resolveContainerHeight(actualHeight)) ?? actualHeight
containerHeight.value = `${Math.max(resolvedHeight, estimatedPreviewHeight.value)}px`
}
}
// Zoom state
const zoom = ref(1)
const translateX = ref(0)
const translateY = ref(0)
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
const renderSignature = computed(() => baseCode.value)
// Tooltip helpers
type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
function shouldSkipEventTarget(el: EventTarget | null) {
const btn = el as HTMLButtonElement | null
return !btn || (btn as HTMLButtonElement).disabled
}
function onBtnHover(e: Event, text: string, place: TooltipPlacement = 'top') {
if (shouldSkipEventTarget(e.currentTarget))
return
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(e.currentTarget as HTMLElement, text, place, false, origin, props.isDark)
}
function onBtnLeave() {
hideTooltip()
}
function onCopyHover(e: Event) {
if (shouldSkipEventTarget(e.currentTarget))
return
const txt = copyText.value ? (t('common.copied') || 'Copied') : (t('common.copy') || 'Copy')
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
showTooltipForAnchor(e.currentTarget as HTMLElement, txt, 'top', false, origin, props.isDark)
}
// Copy functionality
async function copy() {
try {
const text = baseCode.value
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text)
}
copyText.value = true
setTimeout(() => {
copyText.value = false
}, 1000)
}
catch (err) {
console.error('Failed to copy:', err)
}
}
function handleSwitchMode(mode: 'preview' | 'source') {
userToggledShowSource.value = true
showSource.value = mode === 'source'
}
// Export SVG functionality
function handleExportClick() {
const svgElement = infographicContainer.value?.querySelector('svg')
if (!svgElement) {
console.error('SVG element not found')
return
}
exportSvg(svgElement)
}
async function exportSvg(svgElement: SVGElement) {
try {
const svgData = new XMLSerializer().serializeToString(svgElement)
const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
if (typeof document !== 'undefined') {
const link = document.createElement('a')
link.href = url
link.download = `infographic-${Date.now()}.svg`
try {
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
catch {}
URL.revokeObjectURL(url)
}
}
catch (error) {
console.error('Failed to export SVG:', error)
}
}
// Modal/Fullscreen functionality
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isModalOpen.value) {
closeModal()
}
}
function openModal() {
isModalOpen.value = true
if (typeof document !== 'undefined') {
try {
document.body.style.overflow = 'hidden'
}
catch {}
}
if (typeof window !== 'undefined') {
try {
window.addEventListener('keydown', handleKeydown)
}
catch {}
}
nextTick(() => {
if (infographicContainer.value && modalContent.value) {
// Clear previous
modalContent.value.innerHTML = ''
// Create a wrapper for transform
const wrapper = document.createElement('div')
wrapper.style.transition = 'transform 0.1s ease'
wrapper.style.transformOrigin = 'center center'
wrapper.style.width = '100%'
wrapper.style.height = '100%'
wrapper.style.display = 'flex'
wrapper.style.alignItems = 'center'
wrapper.style.justifyContent = 'center'
// Clone the content
const clone = infographicContainer.value.cloneNode(true) as HTMLElement
clone.classList.add('fullscreen')
// Remove any fixed height from the clone to allow it to scale properly in flex
clone.style.height = 'auto'
wrapper.appendChild(clone)
modalContent.value.appendChild(wrapper)
modalCloneWrapper.value = wrapper
// Sync initial transform
wrapper.style.transform = `translate(${translateX.value}px, ${translateY.value}px) scale(${zoom.value})`
}
})
}
function closeModal() {
isModalOpen.value = false
if (modalContent.value) {
modalContent.value.innerHTML = ''
}
modalCloneWrapper.value = null
if (typeof document !== 'undefined') {
try {
document.body.style.overflow = ''
}
catch {}
}
if (typeof window !== 'undefined') {
try {
window.removeEventListener('keydown', handleKeydown)
}
catch {}
}
}
// Keep modal clone in sync with transform changes
function handleOpenModalClick() {
openModal()
}
// Zoom controls
function zoomIn() {
if (zoom.value < 3) {
zoom.value += 0.1
}
}
function zoomOut() {
if (zoom.value > 0.5) {
zoom.value -= 0.1
}
}
function resetZoom() {
zoom.value = 1
translateX.value = 0
translateY.value = 0
}
// Drag functionality
function startDrag(e: MouseEvent | TouchEvent) {
isDragging.value = true
if (e instanceof MouseEvent) {
dragStart.value = {
x: e.clientX - translateX.value,
y: e.clientY - translateY.value,
}
}
else {
dragStart.value = {
x: e.touches[0].clientX - translateX.value,
y: e.touches[0].clientY - translateY.value,
}
}
}
function onDrag(e: MouseEvent | TouchEvent) {
if (!isDragging.value)
return
let clientX: number
let clientY: number
if (e instanceof MouseEvent) {
clientX = e.clientX
clientY = e.clientY
}
else {
clientX = e.touches[0].clientX
clientY = e.touches[0].clientY
}
translateX.value = clientX - dragStart.value.x
translateY.value = clientY - dragStart.value.y
}
function stopDrag() {
isDragging.value = false
}
let infographicInstance: any | null = null
let renderInFlight = false
let rerenderQueued = false
let rerenderForce = false
let lastCompletedRenderSignature = ''
async function renderInfographic(force = false) {
if (!viewportReady.value)
return
if (!infographicContainer.value)
return
if (renderInFlight) {
rerenderQueued = true
rerenderForce = rerenderForce || force
return
}
const signature = renderSignature.value
if (!force && signature === lastCompletedRenderSignature && hasPreview.value)
return
renderInFlight = true
try {
const InfographicClass = await getInfographic()
if (!InfographicClass) {
console.warn('Infographic library failed to load.')
return
}
// Clear previous instance
if (infographicInstance) {
infographicInstance.destroy?.()
infographicInstance = null
}
// Clear container
infographicContainer.value.innerHTML = ''
// Create new instance
infographicInstance = new InfographicClass({
container: infographicContainer.value,
width: '100%',
height: '100%',
})
let renderErrorMessage = ''
infographicInstance.on?.('error', (error: unknown) => {
const errors = Array.isArray(error) ? error : [error]
renderErrorMessage = errors
.map((item) => {
if (item instanceof Error)
return item.message
if (typeof item === 'string')
return item
if (item && typeof item === 'object' && 'message' in item)
return String((item as { message?: unknown }).message ?? '')
return String(item ?? '')
})
.filter(Boolean)
.join('; ')
})
// Render the syntax
infographicInstance.render(baseCode.value)
if (renderErrorMessage)
throw new Error(renderErrorMessage)
if (!infographicContainer.value.childNodes.length)
throw new Error('Infographic render returned empty output.')
hasPreview.value = true
lastCompletedRenderSignature = signature
// Update container height after render
nextTick(() => {
updateContainerHeight()
})
}
catch (error) {
console.error('Failed to render infographic:', error)
hasPreview.value = false
lastCompletedRenderSignature = ''
if (infographicContainer.value) {
infographicContainer.value.innerHTML = `<div style="padding: var(--ms-inset-panel-body); color: hsl(var(--ms-destructive))">Failed to render infographic: ${error instanceof Error ? error.message : 'Unknown error'}</div>`
}
}
finally {
renderInFlight = false
if (rerenderQueued) {
const forceNext = rerenderForce
rerenderQueued = false
rerenderForce = false
nextTick(() => {
void renderInfographic(forceNext)
})
}
}
}
function queueInfographicRender(force = false) {
if (!viewportReady.value || showSource.value || isCollapsed.value)
return
nextTick(() => {
void renderInfographic(force)
})
}
// Watch for code changes
watch(
() => baseCode.value,
() => {
queueInfographicRender(true)
},
)
// Watch for mode changes
watch(
() => showSource.value,
(isSource) => {
if (!isSource)
queueInfographicRender(true)
},
)
// Watch for collapse changes
watch(
() => isCollapsed.value,
(collapsed) => {
if (!collapsed)
queueInfographicRender()
},
)
watch(
() => props.maxHeight,
() => {
nextTick(() => {
updateContainerHeight()
})
},
)
watch(
[() => props.estimatedPreviewHeightPx, () => baseCode.value],
() => {
if (!hasPreview.value && !showSource.value)
containerHeight.value = `${estimatedPreviewHeight.value}px`
},
)
watch(
() => viewportReady.value,
(ready) => {
if (!ready || showSource.value || isCollapsed.value)
return
queueInfographicRender()
},
)
onMounted(() => {
if (!userToggledShowSource.value && isInfographicEnabled())
showSource.value = false
queueInfographicRender()
})
onBeforeUnmount(() => {
viewportHandle.value?.destroy()
viewportHandle.value = null
if (infographicInstance) {
infographicInstance.destroy?.()
infographicInstance = null
}
lastCompletedRenderSignature = ''
if (typeof window !== 'undefined') {
try {
window.removeEventListener('keydown', handleKeydown)
}
catch {}
}
})
const computedButtonStyle = 'infographic-action-btn p-[var(--ms-action-btn-padding)] rounded'
const isFullscreenDisabled = computed(() => showSource.value || isCollapsed.value)
const renderMode = computed(() => {
if (showSource.value)
return 'fallback'
return hasPreview.value ? 'preview' : 'pending'
})
const transformStyle = computed(() => ({
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${zoom.value})`,
}))
// Keep modal clone in sync with transform changes
watch(
transformStyle,
(newStyle) => {
if (isModalOpen.value && modalCloneWrapper.value) {
modalCloneWrapper.value.style.transform = newStyle.transform
}
},
)
</script>
<template>
<div
ref="viewportTarget"
class="infographic-block-container rounded-lg border overflow-hidden"
data-markstream-infographic="1"
:data-markstream-mode="renderMode"
:class="[
{ 'is-rendering': props.loading, 'dark': props.isDark },
]"
>
<!-- Header -->
<div
v-if="props.showHeader"
class="infographic-block-header flex justify-between items-center border-b"
>
<!-- Left side -->
<div v-if="$slots['header-left']">
<slot name="header-left" />
</div>
<div v-else class="flex items-center gap-x-2 overflow-hidden">
<span class="icon-slot action-icon shrink-0" v-html="infographicIcon" />
<span class="infographic-label font-medium font-mono truncate">Infographic</span>
</div>
<!-- Center - Mode toggle -->
<div v-if="$slots['header-center']">
<slot name="header-center" />
</div>
<div v-else-if="props.showModeToggle" class="infographic-mode-toggle flex items-center gap-0.5">
<button
class="infographic-mode-btn px-2 py-0.5 rounded transition-colors"
:class="[!showSource ? 'is-active' : '']"
@click="() => handleSwitchMode('preview')"
@mouseenter="onBtnHover($event, t('common.preview') || 'Preview')"
@focus="onBtnHover($event, t('common.preview') || 'Preview')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<div class="flex items-center gap-x-1">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></g></svg>
<span>{{ t('common.preview') || 'Preview' }}</span>
</div>
</button>
<button
class="infographic-mode-btn px-2 py-0.5 rounded transition-colors"
:class="[showSource ? 'is-active' : '']"
@click="() => handleSwitchMode('source')"
@mouseenter="onBtnHover($event, t('common.source') || 'Source')"
@focus="onBtnHover($event, t('common.source') || 'Source')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<div class="flex items-center gap-x-1">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m16 18l6-6l-6-6M8 6l-6 6l6 6" /></svg>
<span>{{ t('common.source') || 'Source' }}</span>
</div>
</button>
</div>
<!-- Right side - Action buttons -->
<div v-if="$slots['header-right']">
<slot name="header-right" />
</div>
<div v-else class="infographic-header-actions flex items-center">
<button
v-if="props.showCollapseButton"
:class="computedButtonStyle"
:aria-pressed="isCollapsed"
@click="isCollapsed = !isCollapsed"
@mouseenter="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@focus="onBtnHover($event, isCollapsed ? (t('common.expand') || 'Expand') : (t('common.collapse') || 'Collapse'))"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg :style="{ rotate: isCollapsed ? '0deg' : '90deg' }" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 18l6-6l-6-6" /></svg>
</button>
<button
v-if="props.showCopyButton"
:class="computedButtonStyle"
@click="copy"
@mouseenter="onCopyHover($event)"
@focus="onCopyHover($event)"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg v-if="!copyText" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-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 v-else xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 6L9 17l-5-5" /></svg>
</button>
<button
v-if="props.showExportButton"
:class="`${computedButtonStyle} ${isFullscreenDisabled ? 'opacity-50 cursor-not-allowed' : ''}`"
:disabled="isFullscreenDisabled"
@click="handleExportClick"
@mouseenter="onBtnHover($event, t('common.export') || 'Export')"
@focus="onBtnHover($event, t('common.export') || 'Export')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 15V3m9 12v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="m7 10l5 5l5-5" /></g></svg>
</button>
<button
v-if="props.showFullscreenButton"
:class="`${computedButtonStyle} ${isFullscreenDisabled ? 'opacity-50 cursor-not-allowed' : ''}`"
:disabled="isFullscreenDisabled"
@click="handleOpenModalClick"
@mouseenter="onBtnHover($event, isModalOpen ? (t('common.minimize') || 'Minimize') : (t('common.open') || 'Open'))"
@focus="onBtnHover($event, isModalOpen ? (t('common.minimize') || 'Minimize') : (t('common.open') || 'Open'))"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg v-if="!isModalOpen" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-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>
<svg v-else xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-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>
</button>
</div>
</div>
<!-- Content area -->
<div v-show="!isCollapsed">
<div v-if="showSource" class="infographic-source">
<pre class="infographic-source-code text-sm font-mono whitespace-pre-wrap">{{ baseCode }}</pre>
</div>
<div v-else class="relative">
<!-- Zoom controls -->
<div v-if="props.showZoomControls" class="absolute top-2 right-2 z-10 rounded-lg">
<div class="flex items-center gap-2 backdrop-blur rounded-lg">
<button
class="infographic-action-btn p-[var(--ms-action-btn-padding)] rounded"
@click="zoomIn"
@mouseenter="onBtnHover($event, t('common.zoomIn') || 'Zoom in')"
@focus="onBtnHover($event, t('common.zoomIn') || 'Zoom in')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8" /><path d="m21 21l-4.35-4.35M11 8v6m-3-3h6" /></g></svg>
</button>
<button
class="infographic-action-btn p-[var(--ms-action-btn-padding)] rounded"
@click="zoomOut"
@mouseenter="onBtnHover($event, t('common.zoomOut') || 'Zoom out')"
@focus="onBtnHover($event, t('common.zoomOut') || 'Zoom out')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8" /><path d="m21 21l-4.35-4.35M8 11h6" /></g></svg>
</button>
<button
class="infographic-action-btn p-[var(--ms-action-btn-padding)] rounded"
@click="resetZoom"
@mouseenter="onBtnHover($event, t('common.resetZoom') || 'Reset zoom')"
@focus="onBtnHover($event, t('common.resetZoom') || 'Reset zoom')"
@mouseleave="onBtnLeave"
@blur="onBtnLeave"
>
{{ Math.round(zoom * 100) }}%
</button>
</div>
</div>
<div
class="infographic-preview relative transition-all overflow-hidden block"
:style="{ height: containerHeight }"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@touchstart.passive="startDrag"
@touchmove.passive="onDrag"
@touchend.passive="stopDrag"
>
<div
class="absolute inset-0 cursor-grab"
:class="{ 'cursor-grabbing': isDragging }"
:style="transformStyle"
>
<div
ref="infographicContainer"
class="w-full text-center flex items-center justify-center min-h-full"
/>
</div>
</div>
</div>
</div>
<!-- Modal fullscreen overlay (teleported to body) -->
<teleport to="body">
<div class="markstream-vue" :class="{ dark: props.isDark }">
<transition name="infographic-dialog" appear>
<div
v-if="isModalOpen"
class="infographic-modal-overlay fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="closeModal"
>
<div
class="dialog-panel infographic-modal-panel relative w-full h-full max-w-full max-h-full rounded overflow-hidden"
>
<div class="absolute top-6 right-6 z-50 flex items-center gap-2">
<button
class="infographic-action-btn p-[var(--ms-action-btn-padding)] rounded"
@click="zoomIn"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8" /><path d="m21 21l-4.35-4.35M11 8v6m-3-3h6" /></g></svg>
</button>
<button
class="infographic-action-btn p-[var(--ms-action-btn-padding)] rounded"
@click="zoomOut"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="11" cy="11" r="8" /><path d="m21 21l-4.35-4.35M8 11h6" /></g></svg>
</button>
<button
class="infographic-action-btn p-[var(--ms-action-btn-padding)] rounded"
@click="resetZoom"
>
{{ Math.round(zoom * 100) }}%
</button>
<button
class="infographic-action-btn inline-flex items-center justify-center p-[var(--ms-action-btn-padding)] rounded"
@click="closeModal"
>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" width="1em" height="1em" viewBox="0 0 24 24" class="action-icon"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</div>
<div
ref="modalContent"
class="w-full h-full flex items-center justify-center p-4 overflow-hidden"
:class="{ 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
@touchstart.passive="startDrag"
@touchmove.passive="onDrag"
@touchend.passive="stopDrag"
/>
</div>
</div>
</transition>
</div>
</teleport>
</div>
</template>
<style scoped>
/* ── Container ── */
.infographic-block-container {
margin: var(--ms-flow-diagram-y) 0;
background: var(--diagram-bg);
border-color: var(--diagram-border);
color: hsl(var(--ms-foreground));
box-shadow: var(--ms-shadow-subtle);
}
/* ── Header ── */
.infographic-block-header {
padding: var(--ms-inset-panel-y) var(--ms-inset-panel-x);
background: var(--diagram-header-bg);
border-color: var(--diagram-border);
color: hsl(var(--ms-foreground));
}
.infographic-label {
font-size: var(--ms-text-label);
color: hsl(var(--ms-muted-foreground));
}
.action-icon {
width: var(--ms-action-btn-icon);
height: var(--ms-action-btn-icon);
}
.icon-slot {
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-slot :deep(svg) {
display: block;
width: 100%;
height: 100%;
}
/* ── Mode toggle ── */
.infographic-mode-toggle {
background: transparent;
}
.infographic-mode-btn {
font-size: var(--ms-text-label);
color: var(--code-action-fg);
opacity: 0.6;
transition: color 0.15s, background-color 0.15s, opacity 0.15s;
}
.infographic-mode-btn:hover {
opacity: 0.9;
}
.infographic-mode-btn.is-active {
background: hsl(var(--ms-foreground) / 0.08);
color: var(--code-fg);
opacity: 1;
}
.infographic-header-actions {
gap: var(--ms-gap-header-actions);
}
/* ── Action buttons ── */
.infographic-action-btn {
font-family: inherit;
color: var(--code-action-fg);
transition: background-color 0.15s, color 0.15s;
}
.infographic-action-btn:hover {
background: var(--code-action-hover-bg);
color: var(--code-action-hover-fg);
}
.infographic-action-btn:active {
transform: scale(0.98);
}
/* ── Source view ── */
.infographic-source {
padding: var(--ms-inset-panel-body);
background: var(--diagram-bg);
}
.infographic-source-code {
color: hsl(var(--ms-foreground));
}
/* ── Preview area ── */
.infographic-preview {
background: var(--diagram-bg);
min-height: var(--ms-size-diagram-min-height);
transition-duration: var(--ms-duration-fast);
}
/* ── Modal ── */
.infographic-modal-overlay {
background: var(--modal-overlay);
}
.infographic-modal-panel {
background: var(--modal-bg);
color: var(--modal-fg);
box-shadow: var(--ms-shadow-modal);
}
.fullscreen {
width: 100%;
max-height: 100% !important;
height: 100% !important;
}
/* Dialog transition */
.infographic-dialog-enter-from,
.infographic-dialog-leave-to {
opacity: 0;
}
.infographic-dialog-enter-active,
.infographic-dialog-leave-active {
transition: opacity var(--ms-duration-overlay) var(--ms-ease-standard);
}
.infographic-dialog-enter-from .dialog-panel,
.infographic-dialog-leave-to .dialog-panel {
transform: translateY(8px) scale(0.98);
opacity: 0.98;
}
.infographic-dialog-enter-to .dialog-panel,
.infographic-dialog-leave-from .dialog-panel {
transform: translateY(0) scale(1);
opacity: 1;
}
.infographic-dialog-enter-active .dialog-panel,
.infographic-dialog-leave-active .dialog-panel {
transition: transform var(--ms-duration-overlay) var(--ms-ease-standard), opacity var(--ms-duration-overlay) var(--ms-ease-standard);
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import InfographicBlockNode from './InfographicBlockNode.vue'
InfographicBlockNode.install = (app: App) => {
app.component(InfographicBlockNode.__name as string, InfographicBlockNode)
}
export default InfographicBlockNode
@@ -0,0 +1,62 @@
export type InfographicLoader = () => Promise<any> | any
const defaultInfographicLoader: InfographicLoader = () => import('@antv/infographic')
let cachedInfographic: any = null
let infographicLoader: InfographicLoader | null = defaultInfographicLoader
function resetCachedInfographic() {
cachedInfographic = null
}
export function setInfographicLoader(loader: InfographicLoader | null) {
infographicLoader = loader
resetCachedInfographic()
}
export function isInfographicEnabled() {
return infographicLoader !== null
}
export async function getInfographic() {
if (cachedInfographic)
return cachedInfographic
const loader = infographicLoader
if (!loader)
return null
let mod: any
try {
mod = await loader()
}
catch (err) {
if (loader === defaultInfographicLoader) {
throw new Error('Optional dependency "@antv/infographic" is not installed. Please install it to enable infographic diagrams.')
}
throw err
}
if (!mod)
return null
// Normalize module
// Handle ESM default export or named export
const defaultExport = (mod && (mod as any).default) ? (mod as any).default : mod
// If default export is the class itself
if (typeof defaultExport === 'function' && defaultExport.prototype && defaultExport.prototype.render) {
cachedInfographic = defaultExport
}
// If it's a named export { Infographic }
else if (mod.Infographic) {
cachedInfographic = mod.Infographic
}
else if (defaultExport && defaultExport.Infographic) {
cachedInfographic = defaultExport.Infographic
}
else {
// Fallback
cachedInfographic = defaultExport
}
return cachedInfographic
}
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, inject, ref, useAttrs, watch } from 'vue'
const props = defineProps<{
node: {
type: 'inline_code'
code: string
raw: string
}
}>()
const attrs = useAttrs()
const inheritedTypewriter = inject<{ value?: boolean } | undefined>('markstreamTypewriter', undefined)
const inheritedTextStreamState = inject<Map<string, string> | undefined>('markstreamTextStreamState', undefined)
const explicitTypewriter = computed<boolean | undefined>(() => {
const raw = attrs.typewriter
if (raw === '' || raw === true || raw === 'true')
return true
if (raw === false || raw === 'false')
return false
return undefined
})
const typewriterEnabled = computed(() => {
if (typeof explicitTypewriter.value === 'boolean')
return explicitTypewriter.value
if (typeof inheritedTypewriter?.value === 'boolean')
return inheritedTypewriter.value
return true
})
const streamStateKey = computed(() => {
const raw = attrs['index-key'] ?? attrs.indexKey
if (raw == null || raw === '')
return ''
return String(raw)
})
const settledCode = ref(props.node.code)
const streamedDelta = ref('')
const streamFadeVersion = ref(0)
function getRenderedContent() {
return settledCode.value + streamedDelta.value
}
function setFullContent(next: string) {
settledCode.value = next
streamedDelta.value = ''
}
function settleStreamedDelta() {
if (!streamedDelta.value)
return
settledCode.value = getRenderedContent()
streamedDelta.value = ''
}
watch(
[() => props.node.code, streamStateKey, typewriterEnabled],
([next]) => {
const normalized = String(next ?? '')
const rendered = getRenderedContent()
const key = streamStateKey.value
const previousPersisted = key
? inheritedTextStreamState?.get(key)
: undefined
const previousContent = previousPersisted ?? rendered
if (!typewriterEnabled.value) {
setFullContent(normalized)
if (key)
inheritedTextStreamState?.set(key, normalized)
return
}
if (normalized === previousContent) {
setFullContent(normalized)
if (key)
inheritedTextStreamState?.set(key, normalized)
return
}
if (previousContent && normalized.startsWith(previousContent) && normalized.length > previousContent.length) {
settledCode.value = previousContent
streamedDelta.value = normalized.slice(previousContent.length)
streamFadeVersion.value += 1
if (key)
inheritedTextStreamState?.set(key, normalized)
return
}
setFullContent(normalized)
if (key)
inheritedTextStreamState?.set(key, normalized)
},
{ immediate: true },
)
watch(
typewriterEnabled,
(enabled) => {
if (enabled)
return
setFullContent(getRenderedContent())
},
)
const streamedDeltaClass = computed(() => (
streamFadeVersion.value % 2 === 0
? 'inline-code-stream-delta--a'
: 'inline-code-stream-delta--b'
))
</script>
<template>
<code
class="inline-code"
>
<span v-if="settledCode">{{ settledCode }}</span>
<span
v-if="streamedDelta"
class="inline-code-stream-delta" :class="[streamedDeltaClass]"
@animationend="settleStreamedDelta"
>
{{ streamedDelta }}
</span>
</code>
</template>
<style scoped>
.inline-code {
display: inline;
font-family: var(--ms-font-mono);
font-size: 0.8125em;
line-height: inherit;
color: var(--inline-code-fg);
background-color: var(--inline-code-bg);
padding: 0.15em 0.35em;
border-radius: 0.25em;
white-space: normal;
word-break: break-word;
max-width: 100%;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
.inline-code-stream-delta {
animation-duration: var(--stream-update-fade-duration, var(--typewriter-fade-duration, 900ms));
animation-timing-function: var(--stream-update-fade-ease, var(--typewriter-fade-ease, ease-out));
animation-fill-mode: both;
will-change: opacity;
}
.inline-code-stream-delta--a {
animation-name: inline-code-stream-update-fade-a;
}
.inline-code-stream-delta--b {
animation-name: inline-code-stream-update-fade-b;
}
@keyframes inline-code-stream-update-fade-a {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes inline-code-stream-update-fade-b {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.inline-code-stream-delta {
animation: none !important;
}
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import InlineCodeNode from './InlineCodeNode.vue'
InlineCodeNode.install = (app: App) => {
app.component(InlineCodeNode.__name as string, InlineCodeNode)
}
export default InlineCodeNode
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'insert'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
emphasis: EmphasisNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
footnote_reference: FootnoteReferenceNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<ins class="insert-node">
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'insert'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'insert'}-${index}`"
/>
</ins>
</template>
<style scoped>
.insert-node {
text-decoration: underline;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import InsertNode from './InsertNode.vue'
InsertNode.install = (app: App) => {
app.component(InsertNode.__name as string, InsertNode)
}
export default InsertNode
@@ -0,0 +1,220 @@
<script setup lang="ts">
// 定义链接节点
import type { LinkNodeProps } from '../../types/component-props'
import { computed, inject, useAttrs } from 'vue'
import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip'
import { sanitizeAttrs } from '../../utils/htmlRenderer'
import { customComponentsRevision, getCustomNodeComponents } from '../../utils/nodeComponents'
import EmphasisNode from '../EmphasisNode/EmphasisNode.vue'
import HtmlInlineNode from '../HtmlInlineNode'
import ImageNode from '../ImageNode'
import InlineCodeNode from '../InlineCodeNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import TextNode from '../TextNode'
// 接收props — 把动画/颜色相关配置暴露为props,并通过CSS变量注入样式
const props = withDefaults(defineProps<LinkNodeProps>(), {
showTooltip: true,
})
const inheritedShowTooltips = inject<{ value?: boolean } | undefined>('markstreamShowTooltips', undefined)
const tooltipEnabled = computed(() => {
const inherited = inheritedShowTooltips?.value
if (typeof inherited === 'boolean')
return inherited
return props.showTooltip
})
const cssVars = computed(() => {
const bottom = props.underlineBottom !== undefined
? (typeof props.underlineBottom === 'number' ? `${props.underlineBottom}px` : String(props.underlineBottom))
: '-3px'
const activeOpacity = props.animationOpacity ?? 0.35
const restingOpacity = Math.max(0.12, Math.min(activeOpacity * 0.5, activeOpacity))
const vars: Record<string, string> = {
'--underline-height': `${props.underlineHeight ?? 2}px`,
'--underline-bottom': bottom,
'--underline-opacity': String(activeOpacity),
'--underline-rest-opacity': String(restingOpacity),
'--underline-duration': `${props.animationDuration ?? 1.6}s`,
'--underline-timing': props.animationTiming ?? 'ease-in-out',
'--underline-iteration': typeof props.animationIteration === 'number' ? String(props.animationIteration) : (props.animationIteration ?? 'infinite'),
}
if (props.color)
vars['--link-color'] = props.color
return vars
})
// Available node components for child rendering
const nodeComponents = {
text: TextNode,
strong: StrongNode,
strikethrough: StrikethroughNode,
emphasis: EmphasisNode,
image: ImageNode,
html_inline: HtmlInlineNode,
inline_code: InlineCodeNode,
}
const customComponents = computed(() => {
void customComponentsRevision.value
return getCustomNodeComponents(props.customId)
})
// 获取子节点组件,优先使用用户自定义组件
function getChildComponent(child: any) {
const customComponent = customComponents.value[child.type]
if (customComponent)
return customComponent
return nodeComponents[child.type] || null
}
// forward any non-prop attributes (e.g. custom-id) to the rendered element
const attrs = useAttrs()
const nodeAttrs = computed(() => {
const rawAttrs = (props.node as any)?.attrs
if (!rawAttrs || typeof rawAttrs !== 'object')
return {}
const normalized: Record<string, string> = {}
if (Array.isArray(rawAttrs)) {
for (const attr of rawAttrs) {
if (!Array.isArray(attr) || !attr[0])
continue
normalized[String(attr[0])] = String(attr[1] ?? '')
}
}
else {
for (const [key, value] of Object.entries(rawAttrs)) {
if (!key || value == null || value === false)
continue
normalized[key] = value === true ? '' : String(value)
}
}
return sanitizeAttrs(normalized)
})
const anchorAttrs = computed(() => {
const merged = {
...(attrs as Record<string, unknown>),
...nodeAttrs.value,
} as Record<string, unknown>
// `title` is controlled by `showTooltip` behavior and should not be overridden.
delete merged.title
return merged
})
// Tooltip handlers using singleton tooltip
function onAnchorEnter(e: Event) {
if (!tooltipEnabled.value)
return
const ev = e as MouseEvent
const origin = ev?.clientX != null && ev?.clientY != null ? { x: ev.clientX, y: ev.clientY } : undefined
// show the link href in tooltip; fall back to title/text if href missing
const txt = props.node?.title || props.node?.href || props.node?.text || ''
showTooltipForAnchor(e.currentTarget as HTMLElement, txt, 'top', false, origin)
}
function onAnchorLeave() {
if (!tooltipEnabled.value)
return
hideTooltip()
}
const title = computed(() => {
const rawTitle = props.node?.title
if (typeof rawTitle === 'string' && rawTitle.trim().length > 0)
return rawTitle
return String(props.node?.href ?? '')
})
</script>
<template>
<a
v-if="!node.loading"
class="link-node"
:href="node.href"
:title="tooltipEnabled ? '' : title"
:aria-label="`Link: ${title}`"
:aria-hidden="node.loading ? 'true' : 'false'"
target="_blank"
rel="noopener noreferrer"
v-bind="anchorAttrs"
:style="cssVars"
@mouseenter="(e) => onAnchorEnter(e)"
@mouseleave="onAnchorLeave"
>
<component
:is="getChildComponent(child)"
v-for="(child, index) in node.children"
:key="`${indexKey || 'emphasis'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'link-text'}-${index}`"
/>
</a>
<span v-else class="link-loading inline-flex items-baseline gap-1.5" :aria-hidden="!node.loading ? 'true' : 'false'" v-bind="attrs" :style="cssVars">
<span class="link-text-wrapper relative inline-flex">
<span class="leading-[normal] link-text">
<TextNode
class="leading-[normal] link-text"
:node="{ type: 'text', content: String(node.text ?? ''), raw: String(node.text ?? '') }"
:index-key="`${indexKey || 'link-text'}-loading`"
/>
</span>
<span class="link-loading-indicator" aria-hidden="true" />
</span>
</span>
</template>
<style scoped>
.link-node {
color: var(--link-color);
text-decoration: none;
}
.link-node:hover {
text-decoration: underline;
text-underline-offset: .2rem
}
.link-loading .link-text-wrapper {
position: relative;
}
.link-loading {
color: var(--link-color);
}
.link-loading .link-text {
position: relative;
z-index: 2;
}
.link-loading-indicator {
position: absolute;
left: 0;
right: 0;
height: var(--underline-height, 2px);
bottom: var(--underline-bottom, -3px);
background: currentColor;
border-radius: 999px;
will-change: opacity;
opacity: var(--underline-rest-opacity, 0.18);
animation: underlinePulse var(--underline-duration, 1.6s) var(--underline-timing, ease-in-out) var(--underline-iteration, infinite);
}
@keyframes underlinePulse {
0%, 100% { opacity: var(--underline-rest-opacity, 0.18); }
50% { opacity: var(--underline-opacity, 0.35); }
}
@media (prefers-reduced-motion: reduce) {
.link-loading-indicator {
animation: none;
opacity: var(--underline-rest-opacity, 0.18);
}
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import LinkNode from './LinkNode.vue'
LinkNode.install = (app: App) => {
app.component(LinkNode.__name as string, LinkNode)
}
export default LinkNode
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue'
import NodeRenderer from '../NodeRenderer'
// 节点子元素类型
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
// 列表项类型
interface ListItem {
type: 'list_item'
children: NodeChild[]
raw: string
}
const props = defineProps<{
/**
* Preferred prop name for consistency with other node components.
* `item` is kept for backward compatibility.
*/
node?: ListItem
item?: ListItem
indexKey?: number | string
value?: number
customId?: string
/** Forwarded flag to enable/disable non-code node enter transition */
typewriter?: boolean
showTooltips?: boolean
}>()
defineEmits<{
copy: [text: string]
}>()
const itemNode = computed(() => props.node ?? props.item)
const liValueAttr = computed(() =>
props.value == null ? {} : { value: props.value },
)
</script>
<template>
<li class="list-item" dir="auto" v-bind="liValueAttr">
<NodeRenderer
v-bind="{ showTooltips: props.showTooltips }"
:index-key="`list-item-${props.indexKey}`"
:nodes="itemNode?.children ?? []"
:custom-id="props.customId"
:typewriter="props.typewriter"
:batch-rendering="false"
@copy="$emit('copy', $event)"
/>
</li>
</template>
<style scoped>
.list-item {
margin: var(--ms-flow-list-item-y) 0;
padding-left: var(--ms-space-1_5);
}
ol > .list-item::marker{
color: var(--list-counter-marker);
line-height: 1.6;
}
ul > .list-item::marker{
color: var(--list-marker)
}
/* 大列表滚动到视口时,嵌套 NodeRenderer 需要立即绘制内容,避免空白 */
.list-item :deep(.markdown-renderer) {
content-visibility: visible;
contain-intrinsic-size: 0px 0px;
contain: none;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import ListItemNode from './ListItemNode.vue'
ListItemNode.install = (app: App) => {
app.component(ListItemNode.__name as string, ListItemNode)
}
export default ListItemNode
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue'
import { customComponentsRevision, getCustomNodeComponents } from '../../utils/nodeComponents'
import ListItemNode from '../ListItemNode'
// 节点子元素类型
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
// 列表项类型
interface ListItem {
type: 'list_item'
children: NodeChild[]
raw: string
}
const { node, customId, indexKey, typewriter, showTooltips } = defineProps<{
node: {
type: 'list'
ordered: boolean
start?: number
items: ListItem[]
raw: string
}
customId?: string
indexKey?: number | string
typewriter?: boolean
showTooltips?: boolean
}>()
defineEmits(['copy'])
const listItemComponent = computed(() => {
void customComponentsRevision.value
const customComponents = getCustomNodeComponents(customId)
return (customComponents as any).list_item || ListItemNode
})
</script>
<template>
<component
:is="node.ordered ? 'ol' : 'ul'"
class="list-node"
:class="{ 'list-decimal': node.ordered, 'list-disc': !node.ordered }"
>
<component
:is="listItemComponent"
v-for="(item, index) in node.items"
:key="`${indexKey || 'list'}-${index}`"
v-bind="{ showTooltips }"
:node="item"
:custom-id="customId"
:index-key="`${indexKey || 'list'}-${index}`"
:typewriter="typewriter"
:value="node.ordered ? (node.start ?? 1) + index : undefined"
@copy="$emit('copy', $event)"
/>
</component>
</template>
<style scoped>
.list-node {
margin-top: var(--ms-flow-list-y);
margin-bottom: var(--ms-flow-list-y);
padding-left: var(--ms-flow-list-indent);
}
.list-decimal {
list-style-type: decimal;
}
.list-disc {
list-style-type: disc;
}
@media (max-width: 1023px) {
.list-disc {
margin-top: calc(4/3*1em);
margin-bottom: calc(4/3*1em);
padding-left: var(--ms-flow-list-indent-mobile);
}
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import ListNode from './ListNode.vue'
ListNode.install = (app: App) => {
app.component(ListNode.__name as string, ListNode)
}
export default ListNode
@@ -0,0 +1,766 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useSafeI18n } from '../../composables/useSafeI18n'
import { hideTooltip, showTooltipForAnchor } from '../../composables/useSingletonTooltip'
import { useViewportPriority } from '../../composables/viewportPriority'
import { getLanguageIcon, languageIconsRevision, languageMap, normalizeLanguageIdentifier } from '../../utils'
import CodeBlockShell from '../CodeBlockNode/CodeBlockShell.vue'
const props = withDefaults(
defineProps<{
node: {
type: 'code_block'
language: string
code: string
raw: string
diff?: boolean
originalCode?: string
updatedCode?: string
}
loading?: boolean
/**
* If true, update and render code content as it streams in.
* If false, keep a lightweight loading state and create the editor only when loading becomes false.
*/
stream?: boolean
darkTheme?: string
lightTheme?: string
isDark?: boolean
isShowPreview?: boolean
enableFontSizeControl?: boolean
/** Minimum width for the code block container (px or CSS unit string) */
minWidth?: string | number
/** Maximum width for the code block container (px or CSS unit string) */
maxWidth?: string | number
themes?: string[]
/** Header visibility and controls */
showHeader?: boolean
showCopyButton?: boolean
showExpandButton?: boolean
showPreviewButton?: boolean
showCollapseButton?: boolean
showFontSizeButtons?: boolean
/** Toggle singleton tooltips for header action buttons */
showTooltips?: boolean
autoScrollOnUpdate?: boolean
autoScrollInitial?: boolean
estimatedHeightPx?: number
estimatedContentHeightPx?: number
}>(),
{
isShowPreview: true,
// Align defaults with CodeBlockNode behaviour: light first, explicit dark
darkTheme: 'vitesse-dark',
lightTheme: 'vitesse-light',
isDark: false,
loading: true,
stream: true,
enableFontSizeControl: true,
minWidth: undefined,
maxWidth: undefined,
// Header configuration: allow consumers to toggle built-in buttons and header visibility
showHeader: true,
showCopyButton: true,
showExpandButton: true,
showPreviewButton: true,
showCollapseButton: true,
showFontSizeButtons: true,
autoScrollOnUpdate: true,
autoScrollInitial: true,
},
)
const emits = defineEmits(['previewCode', 'copy'])
const { t } = useSafeI18n()
const codeLanguage = ref<string>(normalizeLanguageIdentifier(props.node.language))
const copyText = ref(false)
const isExpanded = ref(false)
const isCollapsed = ref(false)
const container = ref<HTMLElement | null>(null)
const codeBlockContent = ref<HTMLElement | null>(null)
const rendererTarget = ref<HTMLElement | null>(null)
const fallbackHtml = ref('')
const rendererReady = ref(false)
let renderObserver: MutationObserver | undefined
const registerVisibility = useViewportPriority()
const viewportHandle = ref<ReturnType<typeof registerVisibility> | null>(null)
const viewportReady = ref(typeof window === 'undefined')
if (typeof window !== 'undefined') {
watch(
() => container.value,
(el) => {
viewportHandle.value?.destroy()
viewportHandle.value = null
if (!el) {
viewportReady.value = false
return
}
const handle = registerVisibility(el, { rootMargin: '400px' })
viewportHandle.value = handle
viewportReady.value = handle.isVisible.value
handle.whenVisible.then(() => {
viewportReady.value = true
})
},
{ immediate: true },
)
}
// Auto-scroll state management
const autoScrollEnabled = ref(props.autoScrollInitial !== false)
const lastScrollTop = ref(0) // Track last scroll position to detect scroll direction
const shouldAutoScrollOnUpdate = computed(() => props.autoScrollOnUpdate !== false)
// Font size control
const codeFontMin = 10
const codeFontMax = 36
const codeFontStep = 1
const defaultCodeFontSize = ref<number>(14)
const codeFontSize = ref<number>(defaultCodeFontSize.value)
const fontBaselineReady = computed(() => {
const a = defaultCodeFontSize.value
const b = codeFontSize.value
return typeof a === 'number' && Number.isFinite(a) && a > 0 && typeof b === 'number' && Number.isFinite(b) && b > 0
})
// 计算用于显示的语言名称
const displayLanguage = computed(() => {
const lang = codeLanguage.value.trim().toLowerCase()
return languageMap[lang] || lang.charAt(0).toUpperCase() + lang.slice(1)
})
// Computed property for language icon
const languageIcon = computed(() => {
void languageIconsRevision.value
const lang = codeLanguage.value.trim().toLowerCase()
return getLanguageIcon(lang.split(':')[0])
})
// Check if the language is previewable (HTML or SVG)
const isPreviewable = computed(() => {
const lang = codeLanguage.value.trim().toLowerCase()
return props.isShowPreview && (lang === 'html' || lang === 'svg')
})
// Compute inline style for container to respect optional min/max width
const containerStyle = computed(() => {
const s: Record<string, string> = {}
const fmt = (v: string | number | undefined) => {
if (v == null)
return undefined
return typeof v === 'number' ? `${v}px` : String(v)
}
const min = fmt(props.minWidth)
const max = fmt(props.maxWidth)
if (min)
s.minWidth = min
if (max)
s.maxWidth = max
return s
})
const estimatedVisibleContentHeight = computed(() => {
const value = props.estimatedContentHeightPx
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.round(value)
: null
})
const shouldReserveEstimatedContentHeight = computed(() => {
return estimatedVisibleContentHeight.value != null && !rendererReady.value
})
// Computed style for code block content with font size
const contentStyle = computed(() => {
return {
fontSize: `${codeFontSize.value}px`,
...(shouldReserveEstimatedContentHeight.value
? { minHeight: `${estimatedVisibleContentHeight.value}px` }
: {}),
}
})
const tooltipsEnabled = computed(() => props.showTooltips !== false)
function getPreferredColorScheme() {
return props.isDark ? props.darkTheme : props.lightTheme
}
function escapeHtml(str: string) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function renderFallback(code: string) {
renderObserver?.disconnect()
renderObserver = undefined
if (!code) {
fallbackHtml.value = ''
rendererReady.value = false
return
}
fallbackHtml.value = `<pre class="shiki shiki-fallback"><code>${escapeHtml(code)}</code></pre>`
rendererReady.value = false
}
function clearFallback() {
fallbackHtml.value = ''
rendererReady.value = true
}
function hasRendererContent() {
const target = rendererTarget.value
if (!target)
return false
if (target.childNodes.length > 0)
return true
return Boolean(target.textContent?.trim().length)
}
async function clearFallbackWhenRendererReady() {
await nextTick()
if (hasRendererContent()) {
clearFallback()
return
}
const target = rendererTarget.value
if (!target)
return
renderObserver?.disconnect()
renderObserver = new MutationObserver(() => {
if (!hasRendererContent())
return
clearFallback()
renderObserver?.disconnect()
renderObserver = undefined
})
renderObserver.observe(target, { childList: true, subtree: true })
}
interface ShikiRenderer {
updateCode: (code: string, lang?: string) => void | Promise<void>
setTheme: (theme?: string) => void | Promise<void>
dispose: () => void
}
let renderer: ShikiRenderer | undefined
let createShikiRenderer:
| ((el: HTMLElement, opts: { theme?: string | undefined, themes?: string[] | undefined }) => ShikiRenderer)
| undefined
let registerHighlight
let registeredHighlightLanguages: Set<string> | undefined
let registeredHighlightThemesKey: string | null = null
const warnedMissingLanguages = new Set<string>()
const warnedRendererErrors = new Set<string>()
const isDevEnv = typeof import.meta !== 'undefined' && Boolean(import.meta.env?.DEV)
let streamMarkdownLoadPromise: Promise<void> | null = null
function normalizeRendererLanguage(rawLang?: string | null, hasContent = false) {
const [baseToken] = String(rawLang ?? '').split(':')
const normalized = baseToken?.trim().toLowerCase() ?? ''
if (!normalized)
return 'plaintext'
if (!registeredHighlightLanguages || registeredHighlightLanguages.has(normalized))
return normalized
if (hasContent && isDevEnv && !warnedMissingLanguages.has(normalized)) {
warnedMissingLanguages.add(normalized)
console.warn(`[MarkdownCodeBlockNode] Language "${normalized}" not preloaded in stream-markdown; falling back to plaintext.`)
}
return 'plaintext'
}
async function updateRendererWithFallback(code: string, rawLang?: string | null) {
if (!renderer)
return
const normalized = normalizeRendererLanguage(rawLang, Boolean(code && code.length))
try {
await renderer.updateCode(code, normalized)
}
catch (err) {
if (normalized !== 'plaintext') {
if (isDevEnv && !warnedRendererErrors.has(normalized)) {
warnedRendererErrors.add(normalized)
console.warn(`[MarkdownCodeBlockNode] Failed to render language "${normalized}", retrying as plaintext.`, err)
}
await renderer.updateCode(code, 'plaintext')
}
else if (isDevEnv) {
console.warn('[MarkdownCodeBlockNode] Failed to render code block even as plaintext.', err)
}
}
}
async function ensureStreamMarkdownLoaded() {
if (createShikiRenderer)
return
if (streamMarkdownLoadPromise)
return streamMarkdownLoadPromise
streamMarkdownLoadPromise = (async () => {
try {
const mod = await import('stream-markdown')
createShikiRenderer = mod.createShikiStreamRenderer
registerHighlight = mod.registerHighlight
const defaultLangs = Array.isArray((mod as any).defaultLanguages) ? (mod as any).defaultLanguages : undefined
registeredHighlightLanguages = defaultLangs ? new Set(defaultLangs.map((l: string) => l.toLowerCase())) : undefined
ensureHighlightThemesRegistered(props.themes)
}
catch (e) {
console.warn('[MarkdownCodeBlockNode] stream-markdown not available:', e)
}
finally {
streamMarkdownLoadPromise = null
}
})()
return streamMarkdownLoadPromise
}
function ensureHighlightThemesRegistered(themes?: string[]) {
if (!registerHighlight)
return
const nextKey = Array.isArray(themes) ? themes.join('\u0000') : ''
if (registeredHighlightThemesKey === nextKey)
return
registerHighlight({ themes })
registeredHighlightThemesKey = nextKey
}
async function initRenderer() {
if (!viewportReady.value) {
renderFallback(props.node.code)
return
}
await ensureStreamMarkdownLoaded()
if (!codeBlockContent.value || !rendererTarget.value) {
renderFallback(props.node.code)
return
}
ensureHighlightThemesRegistered(props.themes)
if (!renderer && createShikiRenderer) {
renderer = createShikiRenderer(rendererTarget.value, {
theme: getPreferredColorScheme(),
})
rendererReady.value = true
}
if (!renderer) {
renderFallback(props.node.code)
return
}
if (props.stream === false && props.loading) {
renderFallback(props.node.code)
return
}
renderFallback(props.node.code)
await updateRendererWithFallback(props.node.code, codeLanguage.value)
await clearFallbackWhenRendererReady()
}
onMounted(() => {
if (!viewportReady.value) {
renderFallback(props.node.code)
return
}
initRenderer()
})
onBeforeUnmount(() => {
viewportHandle.value?.destroy()
viewportHandle.value = null
renderObserver?.disconnect()
renderObserver = undefined
})
watch(() => props.themes, async () => {
ensureHighlightThemesRegistered(props.themes)
})
watch(() => props.loading, (loading) => {
if (loading)
return
if (!viewportReady.value) {
renderFallback(props.node.code)
return
}
initRenderer()
})
watch(() => viewportReady.value, (ready) => {
if (!ready)
return
initRenderer()
})
watch(tooltipsEnabled, (enabled) => {
if (!enabled)
hideTooltip()
})
watch(() => props.autoScrollInitial, (enabled) => {
autoScrollEnabled.value = enabled !== false
})
watch(() => [props.node.code, props.node.language], async ([code, lang]) => {
const normalizedLang = normalizeLanguageIdentifier(lang)
if (normalizedLang !== codeLanguage.value)
codeLanguage.value = normalizedLang
if (!viewportReady.value) {
renderFallback(code)
return
}
if (!codeBlockContent.value || !rendererTarget.value) {
renderFallback(code)
return
}
if (!renderer) {
renderFallback(code)
await initRenderer()
}
if (!renderer || !code)
return
if (props.stream === false && props.loading)
return
renderFallback(code)
await updateRendererWithFallback(code, lang)
await clearFallbackWhenRendererReady()
})
watch(
() => [props.darkTheme, props.lightTheme, props.isDark],
async () => {
if (!viewportReady.value)
return
if (!codeBlockContent.value || !rendererTarget.value)
return
if (!renderer)
await initRenderer()
renderer?.setTheme(getPreferredColorScheme())
},
)
// Auto-scroll to bottom when content changes (if not expanded and auto-scroll is enabled)
watch(() => props.node.code, async () => {
if (isExpanded.value || !shouldAutoScrollOnUpdate.value || !autoScrollEnabled.value)
return
await nextTick()
const content = codeBlockContent.value
if (!content)
return
// Check if content has scrollbar (scrollHeight > clientHeight)
if (content.scrollHeight > content.clientHeight) {
// Scroll to bottom
content.scrollTop = content.scrollHeight
}
})
// Check if user is at the bottom of scroll area
// Increased threshold to 50px to better detect "near bottom" state
function isAtBottom(element: HTMLElement, threshold = 50): boolean {
return element.scrollHeight - element.scrollTop - element.clientHeight <= threshold
}
// Handle scroll event to detect user interaction
function handleScroll() {
const content = codeBlockContent.value
if (!content || isExpanded.value || !shouldAutoScrollOnUpdate.value)
return
const currentScrollTop = content.scrollTop
// Detect scroll direction: if user scrolls up (scrollTop decreased), disable auto-scroll immediately
if (currentScrollTop < lastScrollTop.value) {
// User is scrolling up - disable auto-scroll
autoScrollEnabled.value = false
}
else if (isAtBottom(content)) {
// User is scrolling down and near bottom - re-enable auto-scroll
autoScrollEnabled.value = true
}
// Update last scroll position
lastScrollTop.value = currentScrollTop
}
// Copy code functionality
async function copy() {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(props.node.code)
}
copyText.value = true
emits('copy', props.node.code)
setTimeout(() => {
copyText.value = false
}, 1000)
}
catch (err) {
console.error('Copy failed:', err)
}
}
// Tooltip helpers
function resolveTooltipTarget(e: Event) {
const btn = (e.currentTarget || e.target) as HTMLButtonElement | null
if (!btn || btn.disabled)
return null
return btn
}
// Expand/collapse functionality
function toggleExpand(e?: Event) {
isExpanded.value = !isExpanded.value
if (e && tooltipsEnabled.value) {
const target = resolveTooltipTarget(e)
if (target) {
const txt = isExpanded.value ? (t('common.collapse') || 'Collapse') : (t('common.expand') || 'Expand')
showTooltipForAnchor(target, txt, 'top', false, undefined, props.isDark)
}
}
const content = codeBlockContent.value
if (!content)
return
if (isExpanded.value) {
content.style.maxHeight = 'none'
content.style['overflow-y'] = 'visible'
content.style['overflow-x'] = 'auto'
}
else {
content.style.maxHeight = ''
content.style.overflow = 'auto'
if (shouldAutoScrollOnUpdate.value) {
autoScrollEnabled.value = true
nextTick(() => {
if (content.scrollHeight > content.clientHeight) {
content.scrollTop = content.scrollHeight
}
})
}
}
}
// Header collapse/fold functionality
function toggleHeaderCollapse() {
isCollapsed.value = !isCollapsed.value
}
// Font size controls
function increaseCodeFont() {
const after = Math.min(codeFontMax, codeFontSize.value + codeFontStep)
codeFontSize.value = after
}
function decreaseCodeFont() {
const after = Math.max(codeFontMin, codeFontSize.value - codeFontStep)
codeFontSize.value = after
}
function resetCodeFont() {
codeFontSize.value = defaultCodeFontSize.value
}
// Preview functionality
function previewCode() {
if (!isPreviewable.value)
return
const lowerLang = (codeLanguage.value || props.node.language).toLowerCase()
const artifactType = lowerLang === 'html' ? 'text/html' : 'image/svg+xml'
const artifactTitle
= lowerLang === 'html'
? 'HTML Preview'
: 'SVG Preview'
emits('previewCode', {
type: artifactType,
content: props.node.code,
title: artifactTitle,
})
}
</script>
<template>
<div
ref="container"
:style="containerStyle"
class="code-block-container rounded-lg border overflow-hidden"
:class="{ dark: props.isDark }"
>
<CodeBlockShell
:show-header="props.showHeader"
:show-collapse-button="props.showCollapseButton"
:show-font-size-buttons="props.showFontSizeButtons"
:enable-font-size-control="props.enableFontSizeControl"
:show-copy-button="props.showCopyButton"
:show-expand-button="props.showExpandButton"
:show-preview-button="props.showPreviewButton"
:show-tooltips="props.showTooltips"
:is-dark="props.isDark"
:loading="props.loading"
:stream="props.stream"
:is-collapsed="isCollapsed"
:is-expanded="isExpanded"
:copy-text="copyText"
:is-previewable="isPreviewable"
:code-font-size="codeFontSize"
:code-font-min="codeFontMin"
:code-font-max="codeFontMax"
:default-code-font-size="defaultCodeFontSize"
:font-baseline-ready="fontBaselineReady"
@toggle-collapse="toggleHeaderCollapse"
@decrease-font="decreaseCodeFont"
@reset-font="resetCodeFont"
@increase-font="increaseCodeFont"
@copy="copy"
@toggle-expand="toggleExpand"
@preview="previewCode"
>
<template #header-left>
<slot name="header-left">
<div class="code-header-main">
<span class="icon-slot h-4 w-4 flex-shrink-0" v-html="languageIcon" />
<div class="code-header-copy">
<div class="code-header-title">
{{ displayLanguage }}
</div>
</div>
</div>
</slot>
</template>
<template v-if="$slots['header-right']" #header-right>
<slot name="header-right" />
</template>
<!-- Content area -->
<div
ref="codeBlockContent"
class="code-block-content"
:style="contentStyle"
@scroll="handleScroll"
>
<div ref="rendererTarget" class="code-block-render" />
<div v-if="!rendererReady" class="code-fallback-plain" v-html="fallbackHtml" />
</div>
<template #loading>
<slot name="loading" :loading="loading" :stream="stream">
<div class="loading-skeleton">
<div class="skeleton-line" />
<div class="skeleton-line" />
<div class="skeleton-line short" />
</div>
</slot>
</template>
</CodeBlockShell>
</div>
</template>
<style scoped>
/* ── Code content ── */
.code-block-content {
display: grid;
max-height: min(70vh, var(--ms-size-code-max-height));
overflow: auto;
transition: max-height var(--ms-duration-slow) var(--ms-ease-standard);
font-family: var(
--markstream-code-font-family,
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Monaco,
Consolas,
Liberation Mono,
Courier New,
monospace
);
font-size: var(--vscode-editor-font-size, 14px);
line-height: var(--vscode-editor-line-height, 1.5);
}
.code-block-render,
.code-fallback-plain {
grid-area: 1 / 1;
min-width: 0;
}
.code-block-render {
min-height: 1px;
}
:deep(.code-block-render pre),
:deep(.code-block-content .shiki) {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
:deep(.code-block-content pre) {
box-sizing: border-box;
margin: 0;
padding: 1rem;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
:deep(.code-block-content .shiki-fallback) {
background: transparent;
color: inherit;
white-space: pre;
}
.code-fallback-plain {
white-space: pre;
overflow: auto;
background: transparent;
color: inherit;
font-size: inherit;
line-height: inherit;
font-family: inherit;
}
/* ── Loading placeholder ── */
.code-loading-placeholder {
padding: 1rem;
min-height: var(--ms-size-skeleton-min-height);
}
.loading-skeleton {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.skeleton-line {
height: 1rem;
background: linear-gradient(90deg, var(--loading-shimmer) 25%, hsl(var(--ms-muted-foreground) / 0.12) 37%, var(--loading-shimmer) 63%);
background-size: 400% 100%;
animation: code-skeleton-shimmer 1.2s ease-in-out infinite;
border-radius: calc(var(--ms-radius) * 0.5);
}
.skeleton-line.short {
width: 60%;
}
@keyframes code-skeleton-shimmer {
0% { background-position: 100% 0; }
100% { background-position: 0 0; }
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import MarkdownCodeBlockNode from './MarkdownCodeBlockNode.vue'
MarkdownCodeBlockNode.install = (app: App) => {
app.component(MarkdownCodeBlockNode.__name as string, MarkdownCodeBlockNode)
}
export default MarkdownCodeBlockNode
@@ -0,0 +1,279 @@
<script setup lang="ts">
import type { MathBlockNodeProps } from '../../types/component-props'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useViewportPriority } from '../../composables/viewportPriority'
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
import { getKatex, getKatexSync } from '../MathInlineNode/katex'
const props = defineProps<MathBlockNodeProps>()
const containerEl = ref<HTMLElement | null>(null)
const mathContent = computed(() => normalizeKaTeXRenderInput(props.node.content))
function resolveInitialState() {
if (!props.node.content) {
return {
html: '',
text: props.node.raw,
loading: false,
}
}
// Prefer a synchronous KaTeX render whenever the loader can provide one so
// SSR and client hydration start from the same markup.
const katex = getKatexSync()
if (!katex) {
return {
html: '',
text: props.node.loading ? '' : props.node.raw,
loading: props.node.loading,
}
}
try {
return {
html: katex.renderToString(mathContent.value, {
throwOnError: props.node.loading,
displayMode: true,
}),
text: '',
loading: false,
}
}
catch {
return {
html: '',
text: props.node.loading ? '' : props.node.raw,
loading: props.node.loading,
}
}
}
const initialState = resolveInitialState()
const renderedHtml = ref(initialState.html)
const renderedText = ref(initialState.text)
let hasRenderedOnce = false
let currentRenderId = 0
let isUnmounted = false
let currentAbortController: AbortController | null = null
const registerVisibility = useViewportPriority()
let visibilityHandle: ReturnType<typeof registerVisibility> | null = null
const renderingLoading = ref(initialState.loading)
if (initialState.html || initialState.text)
hasRenderedOnce = true
// Function to render math using KaTeX
async function renderMath() {
if (isUnmounted)
return
if (!props.node.content) {
renderingLoading.value = false
renderedHtml.value = ''
renderedText.value = props.node.raw
hasRenderedOnce = true
return
}
// Wait until near/in viewport to prioritize visible area
if (!hasRenderedOnce) {
try {
// register once per mount
if (!visibilityHandle && containerEl.value) {
// Observe the outer wrapper to ensure IO triggers even if inner is empty
visibilityHandle = registerVisibility(containerEl.value)
}
await visibilityHandle?.whenVisible
}
catch {}
}
// cancel any previous in-flight render
if (currentAbortController) {
currentAbortController.abort()
currentAbortController = null
}
// increment render id for this invocation; responses from older renders are ignored
const renderId = ++currentRenderId
const abortController = new AbortController()
currentAbortController = abortController
renderKaTeXWithBackpressure(mathContent.value, true, {
timeout: 3000,
waitTimeout: 2000,
maxRetries: 1,
signal: abortController.signal,
})
.then((html) => {
// ignore if a newer render was requested or component unmounted
if (isUnmounted || renderId !== currentRenderId)
return
renderedHtml.value = html
renderedText.value = ''
hasRenderedOnce = true
renderingLoading.value = false
})
.catch(async (err: any) => {
// ignore if a newer render was requested or component unmounted
if (isUnmounted || renderId !== currentRenderId)
return
// If the worker failed to initialize (e.g. bad new Worker path), the
// worker client will return a special error with code 'WORKER_INIT_ERROR'
// and `fallbackToRenderer = true`. In that case, perform a synchronous
// KaTeX render on the main thread as a fallback. If the error is a
// KaTeX render error from the worker (syntax), we should ignore it here
// and fall through to the raw/text fallback below.
const code = err?.code || err?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || err?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
const isDisabled = code === 'KATEX_DISABLED'
// For blocks, also fall back to main-thread render when the worker is busy/timeout
// under viewport bursts to avoid showing raw text.
if (isWorkerInitFailure || isBusyOrTimeout) {
const katex = await getKatex()
if (katex) {
try {
const html = katex.renderToString(mathContent.value, {
throwOnError: props.node.loading,
displayMode: true,
})
renderedHtml.value = html
renderedText.value = ''
hasRenderedOnce = true
renderingLoading.value = false
// populate worker client cache so future calls hit cache
setKaTeXCache(mathContent.value, true, html)
}
catch {
}
return
}
}
// show raw fallback when we never successfully rendered before or when loading flag is false
if (isDisabled) {
renderingLoading.value = false
renderedHtml.value = ''
renderedText.value = props.node.raw
return
}
if (!hasRenderedOnce) {
renderingLoading.value = true
}
if (!props.node.loading) {
renderingLoading.value = false
renderedHtml.value = ''
renderedText.value = props.node.raw
}
})
}
watch(
() => props.node.content,
() => {
renderMath()
},
)
onMounted(() => {
if (renderedHtml.value)
return
renderMath()
})
onBeforeUnmount(() => {
// prevent any pending worker responses from touching the DOM
isUnmounted = true
if (currentAbortController) {
currentAbortController.abort()
currentAbortController = null
}
visibilityHandle?.destroy?.()
visibilityHandle = null
})
</script>
<template>
<div
ref="containerEl"
class="math-block text-center overflow-x-auto relative"
data-markstream-math="block"
:data-markstream-mode="renderedHtml ? 'katex' : renderedText ? 'fallback' : 'loading'"
>
<Transition name="math-fade">
<div v-if="renderingLoading && !renderedHtml && !renderedText" class="math-loading-overlay">
<div class="math-loading-spinner" />
</div>
</Transition>
<div
v-if="renderedHtml"
class="math-block__content"
:class="{ 'math-rendering': renderingLoading }"
v-html="renderedHtml"
/>
<pre v-else-if="renderedText" class="math-block__fallback text-left">{{ renderedText }}</pre>
<div v-else class="math-block__content" :class="{ 'math-rendering': renderingLoading }" />
</div>
</template>
<style scoped>
.math-block {
min-height: var(--ms-size-math-min-height);
}
.math-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
min-height: var(--ms-size-math-min-height);
}
.math-loading-spinner {
width: 20px;
height: 20px;
border: 2px solid color-mix(in srgb, var(--loading-spinner) 15%, transparent);
border-top-color: color-mix(in srgb, var(--loading-spinner) 80%, transparent);
border-radius: 50%;
animation: math-spin 0.8s linear infinite;
}
@keyframes math-spin {
to {
transform: rotate(360deg);
}
}
.math-rendering {
opacity: 0.3;
transition: opacity var(--ms-duration-overlay) var(--ms-ease-standard);
}
.math-block__fallback {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0;
}
.math-fade-enter-active,
.math-fade-leave-active {
transition: opacity var(--ms-duration-slow) var(--ms-ease-standard);
}
.math-fade-enter-from,
.math-fade-leave-to {
opacity: 0;
}
/* Dark mode spinner now handled by --loading-spinner token; no override needed */
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import MathBlockNode from './MathBlockNode.vue'
MathBlockNode.install = (app: App) => {
app.component(MathBlockNode.__name as string, MathBlockNode)
}
export default MathBlockNode
@@ -0,0 +1,283 @@
<script setup lang="ts">
import type { MathInlineNodeProps } from '../../types/component-props'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useViewportPriority } from '../../composables/viewportPriority'
import { normalizeKaTeXRenderInput } from '../../utils/normalizeKaTeXRenderInput'
import { renderKaTeXWithBackpressure, setKaTeXCache, WORKER_BUSY_CODE } from '../../workers/katexWorkerClient'
import { getKatex, getKatexSync } from './katex'
const props = defineProps<MathInlineNodeProps>()
const containerEl = ref<HTMLElement | null>(null)
const isServer = typeof window === 'undefined'
const displayMode = computed(() => props.node.markup === '$$')
const mathContent = computed(() => normalizeKaTeXRenderInput(props.node.content))
function resolveInitialState() {
if (!props.node.content) {
return {
html: '',
text: props.node.raw,
loading: false,
}
}
// Only perform a sync render during SSR so the server and client initial
// markup always match. On the client the post-mount renderMath() call will
// enhance the component, avoiding SSR/client hydration divergence.
if (!isServer) {
return {
html: '',
text: props.node.raw,
loading: false,
}
}
const katex = getKatexSync()
if (!katex) {
return {
html: '',
text: props.node.raw,
loading: false,
}
}
try {
return {
html: katex.renderToString(mathContent.value, {
throwOnError: props.node.loading,
displayMode: displayMode.value,
}),
text: '',
loading: false,
}
}
catch {
return {
html: '',
text: props.node.raw,
loading: false,
}
}
}
const initialState = resolveInitialState()
const renderedHtml = ref(initialState.html)
const renderedText = ref(initialState.text)
let hasRenderedOnce = false
let currentRenderId = 0
let isUnmounted = false
let currentAbortController: AbortController | null = null
const renderingLoading = ref(initialState.loading)
const registerVisibility = useViewportPriority()
let visibilityHandle: ReturnType<typeof registerVisibility> | null = null
if (initialState.html || initialState.text)
hasRenderedOnce = true
async function renderMath() {
if (isUnmounted)
return
if (!props.node.content) {
renderingLoading.value = false
renderedHtml.value = ''
renderedText.value = props.node.raw
hasRenderedOnce = true
return
}
if (currentAbortController) {
currentAbortController.abort()
currentAbortController = null
}
const renderId = ++currentRenderId
const abortController = new AbortController()
currentAbortController = abortController
// Defer heavy work until visible on first render
if (!hasRenderedOnce) {
try {
if (!visibilityHandle && containerEl.value) {
// Observe the always-visible wrapper, not the v-show hidden math span
visibilityHandle = registerVisibility(containerEl.value)
}
await visibilityHandle?.whenVisible
}
catch {}
}
renderKaTeXWithBackpressure(mathContent.value, displayMode.value, {
// Inline math should not wait on worker slots; fallback to sync render immediately
timeout: 1500,
waitTimeout: 0,
maxRetries: 0,
signal: abortController.signal,
})
.then((html) => {
if (isUnmounted || renderId !== currentRenderId)
return
renderedHtml.value = html
renderedText.value = ''
renderingLoading.value = false
hasRenderedOnce = true
})
.catch(async (err: any) => {
if (isUnmounted || renderId !== currentRenderId)
return
// Fallback cases:
// 1) Worker failed to initialize -> try sync render
// 2) Worker is busy/timeout under heavy concurrency -> try sync render to avoid perpetual loading
// (inline math is usually cheap to render on main thread)
const code = err?.code || err?.name
const isWorkerInitFailure = code === 'WORKER_INIT_ERROR' || err?.fallbackToRenderer
const isBusyOrTimeout = code === WORKER_BUSY_CODE || code === 'WORKER_TIMEOUT'
const isDisabled = code === 'KATEX_DISABLED'
if (isWorkerInitFailure || isBusyOrTimeout) {
const katex = await getKatex()
if (katex) {
try {
const html = katex.renderToString(mathContent.value, {
throwOnError: props.node.loading,
displayMode: displayMode.value,
})
renderedHtml.value = html
renderedText.value = ''
renderingLoading.value = false
hasRenderedOnce = true
// populate worker client cache for inline as well
setKaTeXCache(mathContent.value, displayMode.value, html)
}
catch {
}
return
}
}
if (isDisabled) {
renderingLoading.value = false
renderedHtml.value = ''
renderedText.value = props.node.raw
return
}
// If we reach here, the worker render failed and sync fallback was not possible.
// Stop the spinner and show raw text when we have not rendered once yet
// or the node isn't in loading mode.
if (!hasRenderedOnce) {
renderingLoading.value = !isDisabled
}
if (!props.node.loading) {
renderingLoading.value = false
renderedHtml.value = ''
renderedText.value = props.node.raw
}
else if (isDisabled) {
renderedHtml.value = ''
renderedText.value = props.node.raw
}
})
}
watch(
() => props.node.content,
() => {
renderMath()
},
)
onMounted(() => {
if (isServer || renderedHtml.value)
return
renderMath()
})
onBeforeUnmount(() => {
isUnmounted = true
if (currentAbortController) {
currentAbortController.abort()
currentAbortController = null
}
visibilityHandle?.destroy?.()
visibilityHandle = null
})
</script>
<template>
<span
ref="containerEl"
class="math-inline-wrapper"
data-markstream-math="inline"
:data-markstream-mode="renderedHtml ? 'katex' : renderedText ? 'fallback' : 'loading'"
>
<span v-if="renderedHtml" class="math-inline" v-html="renderedHtml" />
<span v-else-if="renderedText" class="math-inline math-inline--fallback">{{ renderedText }}</span>
<transition v-else-if="renderingLoading" name="table-node-fade">
<span
class="math-inline__loading"
role="status"
aria-live="polite"
>
<slot name="loading" :is-loading="renderingLoading">
<span class="math-inline__spinner animate-spin" aria-hidden="true" />
<span class="sr-only">Loading</span>
</slot>
</span>
</transition>
</span>
</template>
<style scoped>
.math-inline-wrapper {
position: relative;
display: inline-block;
}
.math-inline {
display: inline-block;
vertical-align: middle;
}
.math-inline--fallback {
white-space: pre-wrap;
}
.math-inline__loading {
display: inline-flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.math-inline__spinner {
width: 1rem;
height: 1rem;
border-radius: 9999px;
border: 2px solid color-mix(in srgb, var(--loading-spinner) 25%, transparent);
border-top-color: color-mix(in srgb, var(--loading-spinner) 80%, transparent);
will-change: transform;
}
.table-node-fade-enter-active,
.table-node-fade-leave-active {
transition: opacity var(--ms-duration-standard) var(--ms-ease-standard);
}
.table-node-fade-enter-from,
.table-node-fade-leave-to {
opacity: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import MathInlineNode from './MathInlineNode.vue'
MathInlineNode.install = (app: App) => {
app.component(MathInlineNode.__name as string, MathInlineNode)
}
export default MathInlineNode
@@ -0,0 +1,119 @@
export type KatexLoader = () => Promise<any> | any
let katex: any = null
let importAttempted = false
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 g: any = globalThis as any
return normalizeKatexModule(g?.katex)
}
catch {
return null
}
}
function defaultKatexLoader() {
return (async () => {
const globalKatex = getGlobalKatex()
if (globalKatex)
return globalKatex
const mod = await import('katex')
try {
// Webpack 4 does not support `package.json#exports`, so `katex/contrib/mhchem`
// resolves to a directory and fails. Use a real file path instead.
await import('katex/dist/contrib/mhchem')
}
catch {
// ignore missing optional contrib bundle
}
return normalizeKatexModule(mod)
})()
}
function resetCache() {
katex = null
importAttempted = false
}
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 function getKatexSync() {
const globalKatex = getGlobalKatex()
if (globalKatex) {
katex = globalKatex
return katex
}
if (katex)
return katex
const loader = katexLoader
if (!loader || loader === defaultKatexLoader)
return null
try {
const result = loader()
if (!result || typeof (result as PromiseLike<any>)?.then === 'function')
return null
katex = normalizeKatexModule(result) ?? result
return katex
}
catch {
return null
}
}
export async function getKatex() {
const globalKatex = getGlobalKatex()
if (globalKatex) {
katex = globalKatex
return katex
}
if (katex)
return katex
if (importAttempted)
return null
const loader = katexLoader
if (!loader) {
importAttempted = true
return null
}
try {
const result = await loader()
if (result) {
katex = normalizeKatexModule(result) ?? result
return katex
}
}
catch {
// Swallow errors here; callers handle lack of KaTeX support gracefully.
}
importAttempted = true
return null
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import MermaidBlockNode from './MermaidBlockNode.vue'
MermaidBlockNode.install = (app: App) => {
app.component(MermaidBlockNode.__name as string, MermaidBlockNode)
}
export default MermaidBlockNode
@@ -0,0 +1,129 @@
export type MermaidLoader = () => Promise<any> | any
const defaultMermaidLoader: MermaidLoader = () => import('mermaid')
let cachedMermaid: any = null
let mermaidLoader: MermaidLoader | null = defaultMermaidLoader
function resetCachedMermaid() {
cachedMermaid = null
}
function getGlobalMermaid() {
try {
const g: any = globalThis as any
return normalizeMermaidModule(g?.mermaid)
}
catch {
return null
}
}
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'
}
function normalizeMermaidModule(mod: any) {
if (!mod)
return mod
const candidate = (mod && (mod as any).default) ? (mod as any).default : mod
// Common shapes:
// - `candidate.render/parse/initialize` exist -> use candidate
// - `candidate.mermaidAPI` exists with render/parse -> bind those
// - `mod.mermaid` or `mod.default.mermaid` may contain the API
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')) {
// expose a normalized facade that provides render/parse/initialize
const api = candidate.mermaidAPI
return {
...candidate,
render: api.render.bind(api),
parse: api.parse ? api.parse.bind(api) : undefined,
initialize: (opts: any) => {
// some builds expose initialize on root, prefer that
if (typeof candidate.initialize === 'function')
return candidate.initialize(opts)
// otherwise try mermaidAPI.initialize
return api.initialize ? api.initialize(opts) : undefined
},
}
}
if ((mod as any).mermaid && typeof (mod as any).mermaid.render === 'function')
return (mod as any).mermaid
// fallback: use candidate as-is; it may still work or we'll surface a runtime error later
return candidate
}
function patchInitialize(target: any) {
if (!target)
return
// Ensure initialize honors a safe default: suppressErrorRendering = true.
// This prevents mermaid from injecting verbose error diagrams into the DOM
// when parsing/rendering fails. If the consumer passes an explicit
// `suppressErrorRendering: false` it will be respected.
try {
const origInit = target?.initialize
target.initialize = (opts: any) => {
const merged = { suppressErrorRendering: true, ...(opts || {}) }
if (typeof origInit === 'function')
return origInit.call(target, merged)
// fallback to mermaidAPI.initialize if present
if (target?.mermaidAPI && typeof target.mermaidAPI.initialize === 'function')
return target.mermaidAPI.initialize(merged)
return undefined
}
}
catch {
// be defensive: if anything goes wrong wrapping initialize, ignore and
// return the mermaid instance as-is. Consumers will handle runtime errors.
}
}
export async function getMermaid() {
if (cachedMermaid)
return cachedMermaid
const globalMermaid = getGlobalMermaid()
if (globalMermaid) {
cachedMermaid = globalMermaid
patchInitialize(cachedMermaid)
return cachedMermaid
}
const loader = mermaidLoader
if (!loader)
return null
let mod: any
try {
mod = await loader()
}
catch (err) {
if (loader === defaultMermaidLoader) {
throw new Error('Optional dependency "mermaid" is not installed. Please install it to enable mermaid diagrams.')
}
throw err
}
if (!mod)
return null
cachedMermaid = normalizeMermaidModule(mod)
patchInitialize(cachedMermaid)
return cachedMermaid
}
@@ -0,0 +1,15 @@
<script setup lang="ts">
defineProps<{
node: {
raw: string
}
}>()
</script>
<template>
<div class="unknown-node">
{{ node.raw }}
</div>
</template>
<style scoped></style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
export { default } from './NodeRenderer.vue'
@@ -0,0 +1,87 @@
import type { MathBlockNodeProps, MathInlineNodeProps } from '../../types/component-props'
import { defineAsyncComponent, h } from 'vue'
import { getKatex } from '../MathInlineNode/katex'
import TextNode from '../TextNode'
interface ProcessLike {
env?: {
NODE_ENV?: string
}
}
type MathInlineFallbackProps = MathInlineNodeProps & Record<string, unknown>
type MathBlockFallbackProps = MathBlockNodeProps & Record<string, unknown>
function getProcessEnv() {
const processValue = Reflect.get(globalThis, 'process') as ProcessLike | undefined
return processValue?.env
}
export const MathInlineNodeAsync = defineAsyncComponent(async () => {
// In test environment prefer the simple text fallback to avoid
// race conditions with workers/KaTeX rendering.
const isTestEnv = getProcessEnv()?.NODE_ENV === 'test'
if (isTestEnv && typeof window !== 'undefined') {
return (props: MathInlineFallbackProps) => {
// test fallback should be deterministic and minimal
return h(TextNode, {
...props,
node: {
type: 'text',
content: props.node.raw ?? `$${props.node.content ?? ''}$`,
raw: props.node.raw ?? `$${props.node.content ?? ''}$`,
},
})
}
}
try {
const katex = await getKatex()
if (katex) {
const mod = await import('../../components/MathInlineNode')
return mod.default
}
}
catch (e) {
console.warn(
'[markstream-vue] Optional peer dependencies for MathInlineNode are missing. Falling back to text rendering. To enable full math rendering features, please install "katex".',
e,
)
}
return (props: MathInlineFallbackProps) => {
return h(TextNode, {
...props,
node: {
type: 'text',
content: props.node.raw ?? `$${props.node.content ?? ''}$`,
raw: props.node.raw ?? `$${props.node.content ?? ''}$`,
},
})
}
})
export const MathBlockNodeAsync = defineAsyncComponent(async () => {
try {
const katex = await getKatex()
if (katex) {
const mod = await import('../../components/MathBlockNode')
return mod.default
}
}
catch (e) {
console.warn(
'[markstream-vue] Optional peer dependencies for MathBlockNode are missing. Falling back to text rendering. To enable full math rendering features, please install "katex".',
e,
)
}
return (props: MathBlockFallbackProps) => {
return h(TextNode, {
...props,
node: {
type: 'text',
content: props.node.raw ?? `$$${props.node.content ?? ''}$$`,
raw: props.node.raw ?? `$$${props.node.content ?? ''}$$`,
},
})
}
})
@@ -0,0 +1,15 @@
import type { App } from 'vue'
import NodeRenderer from './NodeRenderer.vue'
// Some SFCs (script setup) don't expose a typed `__name` property. Use
// a safe fallback chain: try the internal __name, then the standard name,
// and finally a hard-coded string. Cast to `any` only for the lookup so
// we don't weaken the component type elsewhere.
;
(NodeRenderer as any).install = (app: App) => {
const compName = (NodeRenderer as any).__name ?? (NodeRenderer as any).name ?? 'NodeRenderer'
app.component(compName as string, NodeRenderer)
}
export default NodeRenderer
@@ -0,0 +1,31 @@
let isPreloaded = false
let preloadPromise: Promise<void> | null = null
export async function preload(m: { preloadMonacoWorkers?: () => Promise<unknown> | unknown }) {
if (isPreloaded)
return
if (preloadPromise)
return preloadPromise
const pending = (async () => {
const existingEnv = (globalThis as any)?.MonacoEnvironment
if (existingEnv && (typeof existingEnv.getWorker === 'function' || typeof existingEnv.getWorkerUrl === 'function')) {
isPreloaded = true
return
}
if (typeof m?.preloadMonacoWorkers !== 'function') {
isPreloaded = true
return
}
await m.preloadMonacoWorkers()
isPreloaded = true
})()
preloadPromise = pending.finally(() => {
preloadPromise = null
})
return preloadPromise
}
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { normalizeCustomHtmlTags } from 'stream-markdown-parser'
import { computed } from 'vue'
import { getHtmlTagFromContent, shouldRenderUnknownHtmlTagAsText } from '../../utils/htmlRenderer'
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import CheckboxNode from '../CheckboxNode'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteAnchorNode from '../FootnoteAnchorNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HardBreakNode from '../HardBreakNode'
import HighlightNode from '../HighlightNode'
import HtmlBlockNode from '../HtmlBlockNode'
import HtmlInlineNode from '../HtmlInlineNode'
import ImageNode from '../ImageNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
// Define the type for the node children
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'paragraph'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
customHtmlTags?: readonly string[]
}>()
const overrides = getCustomNodeComponents(props.customId)
function isWhitespaceText(child: NodeChild) {
return child.type === 'text' && String((child as any).content ?? '').trim() === ''
}
function getTextContent(child: NodeChild) {
return String((child as any).content ?? '')
}
function getMeaningfulLinkChildren(child: NodeChild) {
if (child.type !== 'link' || !Array.isArray((child as any).children))
return []
return (child as any).children.filter((linkChild: NodeChild) => !isWhitespaceText(linkChild))
}
function isImageOnlyLink(child: NodeChild) {
const linkChildren = getMeaningfulLinkChildren(child)
return linkChildren.length === 1 && linkChildren[0]?.type === 'image'
}
const meaningfulChildren = computed(() => props.node.children.filter(child => !isWhitespaceText(child)))
const isMediaOnlyParagraph = computed(() => (
meaningfulChildren.value.length > 0
&& meaningfulChildren.value.every(child => child.type === 'image' || isImageOnlyLink(child))
))
const customHtmlTagsSet = computed<Set<string>>(() => {
return new Set(normalizeCustomHtmlTags(props.customHtmlTags))
})
const renderedChildren = computed(() => {
if (!isMediaOnlyParagraph.value || meaningfulChildren.value.length <= 1)
return props.node.children
const children: NodeChild[] = []
for (let i = 0; i < props.node.children.length; i++) {
const child = props.node.children[i]
if (!isWhitespaceText(child)) {
children.push(child)
continue
}
const hasPrevious = children.length > 0
const hasNext = props.node.children.slice(i + 1).some(nextChild => !isWhitespaceText(nextChild))
if (!hasPrevious || !hasNext)
continue
children.push({
...child,
content: ' ',
raw: ' ',
} as NodeChild)
}
return children
})
function getChildProps(child: NodeChild, index: number) {
return {
'node': child,
'index-key': `${props.indexKey}-${index}`,
'custom-id': props.customId,
'custom-html-tags': props.customHtmlTags,
}
}
const nodeComponents = {
inline_code: InlineCodeNode,
image: ImageNode,
link: LinkNode,
hardbreak: HardBreakNode,
emphasis: EmphasisNode,
strong: StrongNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
html_inline: HtmlInlineNode,
html_block: HtmlBlockNode,
emoji: EmojiNode,
checkbox: CheckboxNode,
math_inline: MathInlineNodeAsync,
checkbox_input: CheckboxNode,
reference: ReferenceNode,
footnote_anchor: FootnoteAnchorNode,
footnote_reference: FootnoteReferenceNode,
text: TextNode,
...overrides,
}
// Process children to handle non-whitelisted custom HTML tags
function processChild(child: NodeChild): { child: NodeChild, component: any } {
if (child.type === 'html_block' || child.type === 'html_inline') {
const tag = String((child as any).tag ?? '').trim().toLowerCase()
|| getHtmlTagFromContent((child as any).content)
if (tag && !customHtmlTagsSet.value.has(tag) && shouldRenderUnknownHtmlTagAsText((child as any).content ?? (child as any).raw, tag)) {
const rawContent = String((child as any).content ?? (child as any).raw ?? '')
return {
child: {
type: 'text',
content: rawContent,
raw: rawContent,
} as NodeChild,
component: TextNode,
}
}
}
return { child, component: (nodeComponents as any)[child.type] }
}
</script>
<template>
<p dir="auto" class="paragraph-node">
<template
v-for="(child, index) in renderedChildren"
:key="`${indexKey || 'paragraph'}-${index}`"
>
<template v-if="isMediaOnlyParagraph && isWhitespaceText(child)">
{{ getTextContent(child) }}
</template>
<component
:is="processChild(child).component"
v-else
v-bind="getChildProps(processChild(child).child, index)"
/>
</template>
</p>
</template>
<style scoped>
.paragraph-node{
font-size: var(--ms-text-body);
line-height: var(--ms-leading-body);
margin: var(--ms-flow-paragraph-y) 0;
}
li .paragraph-node{
margin: 0;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import ParagraphNode from './ParagraphNode.vue'
ParagraphNode.install = (app: App) => {
app.component(ParagraphNode.__name as string, ParagraphNode)
}
export default ParagraphNode
@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { PreCodeNodeProps } from '../../types/component-props'
import { computed } from 'vue'
const props = defineProps<PreCodeNodeProps>()
// Normalize language to a safe, lowercase token (fallback to 'plaintext')
const normalizedLanguage = computed(() => {
const raw = String(props.node?.language ?? '')
const head = String(String(raw).split(/\s+/g)[0] ?? '').toLowerCase()
const safe = head.replace(/[^\w-]/g, '')
return safe || 'plaintext'
})
const languageClass = computed(() => `language-${normalizedLanguage.value}`)
const ariaLabel = computed(() => {
const lang = normalizedLanguage.value
return lang ? `Code block: ${lang}` : 'Code block'
})
</script>
<template>
<pre
:class="[languageClass]"
:aria-busy="node.loading === true"
:aria-label="ariaLabel"
:data-language="normalizedLanguage"
data-markstream-pre="1"
tabindex="0"
><code translate="no" v-text="node.code" /></pre>
</template>
<style>
/* Minimal, safe defaults to reduce flicker during frequent text updates */
.markstream-vue pre[class^='language-'],
.markstream-vue pre[class*=' language-'] {
/* Ensure code layout is stable */
white-space: pre;
overflow: auto;
tab-size: 2;
font-variant-ligatures: none;
/* Isolate painting/layout to this block to avoid ancestor reflow jank */
contain: content;
/* Hint GPU compositing on WebKit/Blink to reduce paint flashing */
backface-visibility: hidden;
transform: translateZ(0);
-webkit-font-smoothing: antialiased;
}
.markstream-vue pre[class^='language-'] > code,
.markstream-vue pre[class*=' language-'] > code {
display: block;
}
/* Keyboard accessibility: visible focus when scroll container is focused */
.markstream-vue pre[class^='language-']:focus,
.markstream-vue pre[class*=' language-']:focus {
outline: var(--ms-focus-ring-width) solid var(--focus-ring);
outline-offset: var(--ms-focus-ring-offset);
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import PreCodeNode from './PreCodeNode.vue'
PreCodeNode.install = (app: App) => {
app.component(PreCodeNode.__name as string, PreCodeNode)
}
export default PreCodeNode
@@ -0,0 +1,36 @@
<script setup lang="ts">
defineProps<{
node: {
type: 'reference'
id: string
raw: string
}
messageId?: string
threadId?: string
}>()
defineEmits(['click', 'mouseEnter', 'mouseLeave'])
</script>
<template>
<span
class="reference-node cursor-pointer text-xs rounded-md px-1.5 mx-0.5"
role="button"
tabindex="0"
@click="$emit('click', $event, node.id, messageId, threadId)"
@mouseenter="$emit('mouseEnter', $event, node.id, messageId, threadId)"
@mouseleave="$emit('mouseLeave', $event, node.id, messageId, threadId)"
>
{{ node.id }}
</span>
</template>
<style scoped>
.reference-node {
background-color: hsl(var(--ms-muted));
color: hsl(var(--ms-muted-foreground));
}
.reference-node:hover {
background-color: hsl(var(--ms-secondary));
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import ReferenceNode from './ReferenceNode.vue'
ReferenceNode.install = (app: App) => {
app.component(ReferenceNode.__name as string, ReferenceNode)
}
export default ReferenceNode
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'strikethrough'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: string | number
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
emphasis: EmphasisNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
footnote_reference: FootnoteReferenceNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<del class="strikethrough-node">
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'strikethrough'}-${index}`"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'strikethrough'}-${index}`"
/>
</del>
</template>
<style scoped>
.strikethrough-node {
text-decoration: line-through;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import StrikethroughNode from './StrikethroughNode.vue'
StrikethroughNode.install = (app: App) => {
app.component(StrikethroughNode.__name as string, StrikethroughNode)
}
export default StrikethroughNode
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'strong'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
emphasis: EmphasisNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
footnote_reference: FootnoteReferenceNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<strong class="strong-node">
<component
:is="nodeComponents[child.type]"
v-for="(child, index) in node.children"
:key="`${indexKey || 'strong'}-${index}`"
:index-key="`${indexKey || 'strong'}-${index}`"
:node="child"
:custom-id="props.customId"
/>
</strong>
</template>
<style scoped>
.strong-node {
font-weight: bold;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import StrongNode from './StrongNode.vue'
StrongNode.install = (app: App) => {
app.component(StrongNode.__name as string, StrongNode)
}
export default StrongNode
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SuperscriptNode from '../SuperscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'subscript'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
emphasis: EmphasisNode,
footnote_reference: FootnoteReferenceNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
superscript: SuperscriptNode,
emoji: EmojiNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<sub class="subscript-node">
<template v-for="(child, index) in node.children" :key="`${indexKey || 'subscript'}-${index}`">
<component
:is="nodeComponents[child.type]"
v-if="nodeComponents[child.type]"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'subscript'}-${index}`"
/>
<span v-else>{{ (child as any).content || (child as any).raw }}</span>
</template>
</sub>
</template>
<style scoped>
.subscript-node {
font-size: 0.8em;
vertical-align: sub;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import SubscriptNode from './SubscriptNode.vue'
SubscriptNode.install = (app: App) => {
app.component(SubscriptNode.__name as string, SubscriptNode)
}
export default SubscriptNode
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import EmojiNode from '../EmojiNode'
import EmphasisNode from '../EmphasisNode'
import FootnoteReferenceNode from '../FootnoteReferenceNode'
import HighlightNode from '../HighlightNode'
import HtmlInlineNode from '../HtmlInlineNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import { MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import TextNode from '../TextNode'
interface NodeChild {
type: string
raw: string
[key: string]: unknown
}
const props = defineProps<{
node: {
type: 'superscript'
children: NodeChild[]
raw: string
}
customId?: string
indexKey?: number | string
}>()
const overrides = getCustomNodeComponents(props.customId)
// Available node components for child rendering; prefer custom overrides
const nodeComponents = {
text: TextNode,
inline_code: InlineCodeNode,
link: LinkNode,
html_inline: HtmlInlineNode,
strong: StrongNode,
emphasis: EmphasisNode,
footnote_reference: FootnoteReferenceNode,
strikethrough: StrikethroughNode,
highlight: HighlightNode,
insert: InsertNode,
subscript: SubscriptNode,
emoji: EmojiNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
...overrides,
}
</script>
<template>
<sup class="superscript-node">
<template v-for="(child, index) in node.children" :key="`${indexKey || 'superscript'}-${index}`">
<component
:is="nodeComponents[child.type]"
v-if="nodeComponents[child.type]"
:node="child"
:custom-id="props.customId"
:index-key="`${indexKey || 'superscript'}-${index}`"
/>
<span v-else>{{ (child as any).content || (child as any).raw }}</span>
</template>
</sup>
</template>
<style scoped>
.superscript-node {
font-size: 0.8em;
vertical-align: super;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import SuperscriptNode from './SuperscriptNode.vue'
SuperscriptNode.install = (app: App) => {
app.component(SuperscriptNode.__name as string, SuperscriptNode)
}
export default SuperscriptNode
@@ -0,0 +1,412 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import NodeRenderer from '../NodeRenderer'
interface TableCellNode {
type: 'table_cell'
header: boolean
children: {
type: string
raw: string
}[]
raw: string
align?: 'left' | 'right' | 'center'
}
interface TableRowNode {
type: 'table_row'
cells: TableCellNode[]
raw: string
}
interface TableNode {
type: 'table'
header: TableRowNode
rows: TableRowNode[]
raw: string
loading: boolean
}
const props = defineProps<{
node: TableNode
indexKey: string | number
isDark?: boolean
typewriter?: boolean
customId?: string
}>()
defineEmits(['copy'])
const isLoading = computed(() => props.node.loading ?? false)
const bodyRows = computed(() => props.node.rows ?? [])
const tableRef = ref<HTMLTableElement | null>(null)
const columnWidths = ref<number[]>([])
const MIN_COLUMN_WIDTH = 48
let resizeState: {
index: number
startX: number
startWidth: number
nextStartWidth: number
widths: number[]
} | null = null
const columnStyles = computed(() =>
columnWidths.value.map(width => width > 0 ? { width: `${width}px` } : undefined),
)
const hasColumnWidths = computed(() => columnWidths.value.length > 0)
function measureHeaderWidths() {
const cells = tableRef.value?.querySelectorAll('thead th')
return Array.from(cells ?? [], cell => Math.round(cell.getBoundingClientRect().width))
}
function onColumnResizeMove(event: PointerEvent) {
if (!resizeState)
return
event.preventDefault()
const pairWidth = resizeState.startWidth + resizeState.nextStartWidth
const minWidth = Math.min(MIN_COLUMN_WIDTH, Math.floor(pairWidth / 2))
const width = Math.max(
minWidth,
Math.min(pairWidth - minWidth, Math.round(resizeState.startWidth + event.clientX - resizeState.startX)),
)
const nextWidths = [...resizeState.widths]
nextWidths[resizeState.index] = width
nextWidths[resizeState.index + 1] = pairWidth - width
columnWidths.value = nextWidths
}
function stopColumnResize() {
if (!resizeState)
return
window.removeEventListener('pointermove', onColumnResizeMove)
window.removeEventListener('pointerup', stopColumnResize)
window.removeEventListener('pointercancel', stopColumnResize)
resizeState = null
}
function startColumnResize(index: number, event: PointerEvent) {
if (event.button !== 0)
return
const widths = measureHeaderWidths()
const startWidth = widths[index]
const nextStartWidth = widths[index + 1]
if (!startWidth || !nextStartWidth)
return
event.preventDefault()
resizeState = {
index,
startX: event.clientX,
startWidth,
nextStartWidth,
widths,
}
columnWidths.value = widths
window.addEventListener('pointermove', onColumnResizeMove)
window.addEventListener('pointerup', stopColumnResize)
window.addEventListener('pointercancel', stopColumnResize)
}
watch(
() => props.node.header.cells.length,
() => {
stopColumnResize()
columnWidths.value = []
},
)
onBeforeUnmount(stopColumnResize)
</script>
<template>
<div class="table-node-wrapper">
<table
ref="tableRef"
class="table-node"
:class="{ 'table-node--loading': isLoading }"
:aria-busy="isLoading"
>
<colgroup v-if="hasColumnWidths">
<col
v-for="(_, index) in node.header.cells"
:key="`col-${index}`"
:style="columnStyles[index]"
>
</colgroup>
<thead>
<tr>
<th
v-for="(cell, index) in node.header.cells"
:key="`header-${index}`"
dir="auto"
:class="[
cell.align === 'right'
? 'text-right'
: cell.align === 'center'
? 'text-center'
: 'text-left',
]"
>
<NodeRenderer
:nodes="cell.children"
:index-key="`table-th-${props.indexKey}`"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
<button
v-if="index < node.header.cells.length - 1"
type="button"
class="table-node__resize-handle"
:aria-label="`Resize columns ${index + 1} and ${index + 2}`"
@pointerdown="startColumnResize(index, $event)"
/>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in bodyRows"
:key="`row-${rowIndex}`"
>
<td
v-for="(cell, cellIndex) in row.cells"
:key="`cell-${rowIndex}-${cellIndex}`"
:class="[
cell.align === 'right'
? 'text-right'
: cell.align === 'center'
? 'text-center'
: 'text-left',
]"
dir="auto"
>
<NodeRenderer
:nodes="cell.children"
:index-key="`table-td-${props.indexKey}`"
:custom-id="props.customId"
:typewriter="props.typewriter"
@copy="$emit('copy', $event)"
/>
</td>
</tr>
</tbody>
</table>
<transition name="table-node-fade">
<div v-if="isLoading" class="table-node__loading" role="status" aria-live="polite">
<slot name="loading" :is-loading="isLoading">
<span class="table-node__spinner animate-spin" aria-hidden="true" />
<span class="sr-only">Loading</span>
</slot>
</div>
</transition>
</div>
</template>
<style scoped>
.table-node-wrapper {
position: relative;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
overscroll-behavior-y: auto;
scrollbar-gutter: stable;
}
.table-node {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: var(--ms-flow-table-y) 0;
font-size: inherit;
border: 1px solid var(--table-border);
border-radius: var(--ms-radius);
overflow: hidden;
box-shadow: var(--ms-shadow-subtle);
}
/* ── Full grid borders (inner only, outer handled by table border) ── */
.table-node :deep(th),
.table-node :deep(td) {
border-bottom: 1px solid var(--table-border);
border-right: 1px solid var(--table-border);
padding: var(--ms-flow-table-cell);
white-space: normal;
overflow-wrap: break-word;
word-break: normal;
}
/* Remove right border on last cell */
.table-node :deep(th:last-child),
.table-node :deep(td:last-child) {
border-right: none;
}
/* Remove bottom border on last row */
.table-node :deep(tbody tr:last-child td) {
border-bottom: none;
}
/* ── Header ── */
.table-node :deep(thead th) {
position: relative;
font-weight: 600;
background-color: var(--table-header-bg);
border-bottom-width: 2px;
}
.table-node__resize-handle {
position: absolute;
top: 0;
right: -4px;
bottom: 0;
z-index: 1;
width: 8px;
padding: 0;
border: 0;
background: transparent;
cursor: col-resize;
touch-action: none;
}
.table-node__resize-handle::after {
content: '';
position: absolute;
top: 0.35em;
bottom: 0.35em;
left: 50%;
width: 2px;
border-radius: 9999px;
background: color-mix(in srgb, var(--table-border) 45%, hsl(var(--ms-foreground)));
opacity: 0;
transform: translateX(-50%);
transition: opacity var(--ms-duration-fast) var(--ms-ease-standard);
}
.table-node__resize-handle:hover::after,
.table-node__resize-handle:focus-visible::after {
opacity: 1;
}
/* ── Zebra striping (very subtle) ── */
.table-node :deep(tbody tr:nth-child(even)) {
background-color: hsl(var(--ms-muted) / 0.35);
}
/* ── Row hover ── */
.table-node :deep(tbody tr):hover {
background-color: var(--code-action-hover-bg);
}
/* ── Loading ── */
.table-node--loading tbody td {
position: relative;
overflow: hidden;
}
.table-node--loading tbody td > * {
visibility: hidden;
}
.table-node--loading tbody td::after {
content: '';
position: absolute;
inset: 0;
border-radius: calc(var(--ms-radius) * 0.5);
background: linear-gradient(
90deg,
var(--loading-shimmer) 25%,
var(--loading-shimmer) 50%,
var(--loading-shimmer) 75%
);
background-size: 200% 100%;
animation: table-node-shimmer 1.2s linear infinite;
will-change: background-position;
}
.table-node__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.table-node__spinner {
width: 2.5rem;
height: 2.5rem;
border-radius: 9999px;
border: 2px solid color-mix(in srgb, var(--loading-spinner) 25%, transparent);
border-top-color: color-mix(in srgb, var(--loading-spinner) 80%, transparent);
will-change: transform;
}
.table-node-fade-enter-active,
.table-node-fade-leave-active {
transition: opacity var(--ms-duration-standard) var(--ms-ease-standard);
}
.table-node-fade-enter-from,
.table-node-fade-leave-to {
opacity: 0;
}
/* ── Table inside NodeRenderer ── */
:deep(.table-node .markdown-renderer) {
display: contents;
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0px 0px;
}
:deep(.table-node .markdown-renderer .node-slot),
:deep(.table-node .markdown-renderer .node-content),
:deep(.table-node .markdown-renderer .node-space) {
display: contents;
}
:deep(.table-node .text-node),
:deep(.table-node code) {
white-space: inherit;
overflow-wrap: inherit;
word-break: inherit;
max-width: none;
}
@keyframes table-node-shimmer {
0% { background-position: 0% 0%; }
50% { background-position: 100% 0%; }
100% { background-position: 200% 0%; }
}
.hr + .table-node-wrapper {
margin-top: 0;
}
.hr + .table-node-wrapper .table-node {
margin-top: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import TableNode from './TableNode.vue'
TableNode.install = (app: App) => {
app.component(TableNode.__name as string, TableNode)
}
export default TableNode
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, inject, ref, useAttrs, watch } from 'vue'
import { useKatexReady } from '../../composables/useKatexReady'
const props = defineProps<{
node: {
type: 'text'
content: string
raw: string
center?: boolean
}
}>()
defineEmits(['copy'])
const katexReady = useKatexReady()
const attrs = useAttrs()
const inheritedTypewriter = inject<{ value?: boolean } | undefined>('markstreamTypewriter', undefined)
const inheritedTextStreamState = inject<Map<string, string> | undefined>('markstreamTextStreamState', undefined)
const inheritedStreamVersion = inject<{ value?: number } | undefined>('markstreamStreamVersion', undefined)
const explicitTypewriter = computed<boolean | undefined>(() => {
const raw = attrs.typewriter
if (raw === '' || raw === true || raw === 'true')
return true
if (raw === false || raw === 'false')
return false
return undefined
})
const typewriterEnabled = computed(() => {
if (typeof explicitTypewriter.value === 'boolean')
return explicitTypewriter.value
if (typeof inheritedTypewriter?.value === 'boolean')
return inheritedTypewriter.value
return true
})
const streamStateKey = computed(() => {
const raw = attrs['index-key'] ?? attrs.indexKey
if (raw == null || raw === '')
return ''
return String(raw)
})
const settledContent = ref(props.node.content)
const streamedDelta = ref('')
const streamFadeVersion = ref(0)
function getRenderedContent() {
return settledContent.value + streamedDelta.value
}
function setFullContent(next: string) {
settledContent.value = next
streamedDelta.value = ''
}
function settleStreamedDelta() {
if (!streamedDelta.value)
return
settledContent.value = getRenderedContent()
streamedDelta.value = ''
}
watch(
[() => props.node.content, streamStateKey, typewriterEnabled, () => inheritedStreamVersion?.value],
([next]) => {
const normalized = String(next ?? '')
const rendered = getRenderedContent()
const key = streamStateKey.value
const previousPersisted = key
? inheritedTextStreamState?.get(key)
: undefined
const previousContent = previousPersisted ?? rendered
if (!typewriterEnabled.value) {
setFullContent(normalized)
if (key)
inheritedTextStreamState?.set(key, normalized)
return
}
if (normalized === previousContent) {
if (streamedDelta.value)
settleStreamedDelta()
else if (rendered !== normalized)
setFullContent(normalized)
if (key)
inheritedTextStreamState?.set(key, normalized)
return
}
if (previousContent && normalized.startsWith(previousContent) && normalized.length > previousContent.length) {
settledContent.value = previousContent
streamedDelta.value = normalized.slice(previousContent.length)
streamFadeVersion.value += 1
if (key)
inheritedTextStreamState?.set(key, normalized)
return
}
setFullContent(normalized)
if (key)
inheritedTextStreamState?.set(key, normalized)
},
{ immediate: true },
)
watch(
typewriterEnabled,
(enabled) => {
if (enabled)
return
setFullContent(getRenderedContent())
},
)
const streamedDeltaClass = computed(() => (
streamFadeVersion.value % 2 === 0
? 'text-node-stream-delta--a'
: 'text-node-stream-delta--b'
))
</script>
<template>
<span
:class="[katexReady && node.center ? 'text-node-center' : '']"
class="whitespace-pre-wrap break-words text-node"
>
<span v-if="settledContent">{{ settledContent }}</span>
<span
v-if="streamedDelta"
class="text-node-stream-delta" :class="[streamedDeltaClass]"
@animationend="settleStreamedDelta"
>
{{ streamedDelta }}
</span>
</span>
</template>
<style scoped>
.text-node {
display: inline;
font-weight: inherit;
vertical-align: baseline;
}
.text-node-center {
display: inline-flex;
justify-content: center;
width: 100%;
}
.text-node-stream-delta {
animation-duration: var(--stream-update-fade-duration, var(--typewriter-fade-duration, 900ms));
animation-timing-function: var(--stream-update-fade-ease, var(--typewriter-fade-ease, ease-out));
animation-fill-mode: both;
will-change: opacity;
}
.text-node-stream-delta--a {
animation-name: text-node-stream-update-fade-a;
}
.text-node-stream-delta--b {
animation-name: text-node-stream-update-fade-b;
}
@keyframes text-node-stream-update-fade-a {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes text-node-stream-update-fade-b {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.text-node-stream-delta {
animation: none !important;
}
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import TextNode from './TextNode.vue'
TextNode.install = (app: App) => {
app.component(TextNode.__name as string, TextNode)
}
export default TextNode
@@ -0,0 +1,12 @@
<script setup lang="ts"></script>
<template>
<hr class="hr-node">
</template>
<style scoped>
.hr-node {
@apply border-t border-[var(--hr-border)];
margin: var(--ms-flow-hr-y) 0;
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import ThematicBreakNode from './ThematicBreakNode.vue'
ThematicBreakNode.install = (app: App) => {
app.component(ThematicBreakNode.__name as string, ThematicBreakNode)
}
export default ThematicBreakNode
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { arrow as arrowMiddleware, autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps<{
visible: boolean
anchorEl: HTMLElement | null
content: string
placement?: 'top' | 'bottom' | 'left' | 'right'
offset?: number
originX?: number | null
originY?: number | null
id?: string | null
/** @deprecated Dark mode is now handled via semantic CSS tokens. */
isDark?: boolean | null
}>()
const tooltip = ref<HTMLElement | null>(null)
const arrowEl = ref<HTMLElement | null>(null)
const style = ref<Record<string, string>>({ transform: 'translate3d(0px, 0px, 0px)', left: '0px', top: '0px' })
const arrowStyle = ref<Record<string, string>>({})
const actualPlacement = ref<string>(props.placement ?? 'top')
const ready = ref(false)
let cleanupAutoUpdate: (() => void) | null = null
async function updatePosition() {
if (!props.anchorEl || !tooltip.value)
return
const middleware = [
offset(props.offset ?? 6),
flip(),
shift({ padding: 6 }),
...(arrowEl.value ? [arrowMiddleware({ element: arrowEl.value, padding: 4 })] : []),
]
const { x, y, placement, middlewareData } = await computePosition(props.anchorEl, tooltip.value, {
placement: props.placement ?? 'top',
middleware,
strategy: 'fixed',
})
style.value.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`
style.value.left = '0px'
style.value.top = '0px'
actualPlacement.value = placement
// Arrow positioning
if (middlewareData.arrow && arrowEl.value) {
const { x: ax, y: ay } = middlewareData.arrow
const side = placement.split('-')[0] as 'top' | 'bottom' | 'left' | 'right'
const staticSide = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[side]
arrowStyle.value = {
left: ax != null ? `${ax}px` : '',
top: ay != null ? `${ay}px` : '',
[staticSide]: '-3px',
}
}
}
watch(
() => props.visible,
async (v) => {
if (v) {
ready.value = false
await nextTick()
if (props.anchorEl && tooltip.value) {
try {
const rect = props.anchorEl.getBoundingClientRect()
await updatePosition()
const targetTransform = style.value.transform
if (props.originX != null && props.originY != null) {
const dx = Math.abs(Number(props.originX) - rect.left)
const dy = Math.abs(Number(props.originY) - rect.top)
const dist = Math.hypot(dx, dy)
if (dist > 120) {
style.value.transform = `translate3d(${Math.round(props.originX)}px, ${Math.round(props.originY)}px, 0)`
await nextTick()
ready.value = true
await nextTick()
style.value.transform = targetTransform
}
else {
ready.value = true
}
}
else {
ready.value = true
}
cleanupAutoUpdate = autoUpdate(props.anchorEl, tooltip.value, updatePosition)
}
catch {
await updatePosition()
ready.value = true
cleanupAutoUpdate = autoUpdate(props.anchorEl, tooltip.value, updatePosition)
}
}
else {
ready.value = true
}
}
else {
ready.value = false
if (cleanupAutoUpdate) {
cleanupAutoUpdate()
cleanupAutoUpdate = null
}
}
},
)
watch([
() => props.anchorEl,
() => props.placement,
() => props.content,
], async () => {
if (props.visible && props.anchorEl && tooltip.value) {
await nextTick()
await updatePosition()
}
})
onBeforeUnmount(() => {
if (cleanupAutoUpdate)
cleanupAutoUpdate()
})
</script>
<template>
<teleport to="body">
<div class="markstream-vue" :class="{ dark: isDark }">
<transition name="tooltip" appear>
<div
v-show="visible && ready"
:id="props.id"
ref="tooltip"
:style="{ position: 'fixed', left: style.left, top: style.top, transform: style.transform }"
class="tooltip-element"
role="tooltip"
>
{{ content }}
<div ref="arrowEl" class="tooltip-arrow" :data-placement="actualPlacement" :style="arrowStyle" />
</div>
</transition>
</div>
</teleport>
</template>
<style scoped>
.tooltip-element {
z-index: 9999;
display: inline-block;
max-width: 20rem;
padding: 0.25rem 0.5rem;
border-radius: calc(var(--ms-radius) * 0.75);
font-size: 0.75rem;
line-height: 1.4;
white-space: normal;
word-break: break-word;
pointer-events: none;
background-color: var(--tooltip-bg);
color: var(--tooltip-fg);
box-shadow: inset 0 1px 0 0 hsl(0 0% 100% / 0.15), 0 0 0 1px hsl(0 0% 0% / 0.12), var(--ms-shadow-popover);
transition: transform var(--ms-duration-emphasis) var(--ms-ease-spring),
box-shadow var(--ms-duration-emphasis) var(--ms-ease-spring);
}
/* Arrow */
.tooltip-arrow {
position: absolute;
width: 6px;
height: 6px;
background: inherit;
transform: rotate(45deg);
}
.tooltip-arrow[data-placement^="top"] {
bottom: -3px;
}
.tooltip-arrow[data-placement^="bottom"] {
top: -3px;
}
.tooltip-arrow[data-placement^="left"] {
right: -3px;
}
.tooltip-arrow[data-placement^="right"] {
left: -3px;
}
/* Transitions */
.tooltip-enter-active {
transition: opacity 180ms cubic-bezier(.16, 1, .3, 1),
transform 180ms cubic-bezier(.16, 1, .3, 1);
}
.tooltip-leave-active {
transition: opacity 120ms ease-in,
transform 120ms ease-in;
}
.tooltip-enter-from {
opacity: 0;
transform: scale(0.96);
}
.tooltip-enter-to,
.tooltip-leave-from {
opacity: 1;
transform: scale(1);
}
.tooltip-leave-to {
opacity: 0;
transform: scale(0.97);
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import Tooltip from './Tooltip.vue'
Tooltip.install = (app: App) => {
app.component(Tooltip.__name as string, Tooltip)
}
export default Tooltip
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getCustomNodeComponents } from '../../utils/nodeComponents'
import CheckboxNode from '../CheckboxNode'
import EmphasisNode from '../EmphasisNode'
import HardBreakNode from '../HardBreakNode'
import HeadingNode from '../HeadingNode'
import ImageNode from '../ImageNode'
import InlineCodeNode from '../InlineCodeNode'
import InsertNode from '../InsertNode'
import LinkNode from '../LinkNode'
import ListNode from '../ListNode'
import { MathBlockNodeAsync, MathInlineNodeAsync } from '../NodeRenderer/asyncComponent'
import FallbackComponent from '../NodeRenderer/FallbackComponent.vue'
import ParagraphNode from '../ParagraphNode'
import ReferenceNode from '../ReferenceNode'
import StrikethroughNode from '../StrikethroughNode'
import StrongNode from '../StrongNode'
import SubscriptNode from '../SubscriptNode'
import SuperscriptNode from '../SuperscriptNode'
import TableNode from '../TableNode'
import TextNode from '../TextNode'
interface VmrContainerNode {
type: 'vmr_container'
name: string
loading?: boolean
attrs?: Record<string, string>
children: { type: string, raw: string }[]
raw: string
}
const props = defineProps<{ node: VmrContainerNode, indexKey: number | string, isDark?: boolean, typewriter?: boolean, customId?: string }>()
// Build CSS class from container name
const containerClass = computed(() => `vmr-container vmr-container-${props.node.name}`)
const overrides = getCustomNodeComponents(props.customId)
const nodeComponents = {
// Inline nodes (used inside paragraphs)
text: TextNode,
paragraph: ParagraphNode,
heading: HeadingNode,
inline_code: InlineCodeNode,
link: LinkNode,
image: ImageNode,
strong: StrongNode,
emphasis: EmphasisNode,
strikethrough: StrikethroughNode,
insert: InsertNode,
subscript: SubscriptNode,
superscript: SuperscriptNode,
checkbox: CheckboxNode,
checkbox_input: CheckboxNode,
hardbreak: HardBreakNode,
math_inline: MathInlineNodeAsync,
reference: ReferenceNode,
list: ListNode,
math_block: MathBlockNodeAsync,
table: TableNode,
// Custom overrides
...overrides,
}
function getNodeComponent(type: string) {
return nodeComponents[type] || FallbackComponent
}
</script>
<template>
<div :class="containerClass" v-bind="node.attrs">
<component
:is="getNodeComponent(child.type)"
v-for="(child, index) in node.children"
:key="`${indexKey || 'vmr-container'}-${index}`"
:custom-id="props.customId"
:node="child"
:index-key="`${indexKey || 'vmr-container'}-${index}`"
/>
</div>
</template>
<style scoped>
.vmr-container {
@apply rounded-lg border p-4 my-4;
border-left-width: var(--ms-border-width-strong);
}
</style>
@@ -0,0 +1,8 @@
import type { App } from 'vue'
import VmrContainerNode from './VmrContainerNode.vue'
VmrContainerNode.install = (app: App) => {
app.component(VmrContainerNode.__name as string, VmrContainerNode)
}
export default VmrContainerNode
@@ -0,0 +1,701 @@
/**
* @vitest-environment jsdom
*/
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import { defineComponent, h, nextTick } from 'vue'
import { flushAll } from '../../../test/setup/flush-all'
import HtmlBlockNode from '../../components/HtmlBlockNode/HtmlBlockNode.vue'
import HtmlInlineNode from '../../components/HtmlInlineNode/HtmlInlineNode.vue'
import MarkdownRender from '../../components/NodeRenderer'
import { setCustomComponents } from '../../utils/nodeComponents'
// Mock custom components
const TestComponent = defineComponent({
name: 'TestComponent',
props: ['dataType', 'active'],
setup(props, { slots }) {
return () => h(
'div',
{
'class': 'test-component',
'data-type': props.dataType,
'data-active': props.active,
},
slots.default?.() || 'Test Component',
)
},
})
const NestedComponent = defineComponent({
name: 'NestedComponent',
setup(_, { slots }) {
return () => h('div', { class: 'nested-component' }, slots.default?.())
},
})
describe('htmlBlockNode - Custom Components Integration', () => {
const testId = 'test-html-block'
beforeEach(() => {
setCustomComponents(testId, {
testcomp: TestComponent,
nestedcomp: NestedComponent,
})
})
it('should render custom component in HTML block', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<testcomp data-type="block">Content</testcomp>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.find('.test-component').attributes('data-type')).toBe('block')
expect(wrapper.html()).toContain('Content')
})
it('should render nested custom components', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<testcomp data-type="outer">Outer <nestedcomp>Nested</nestedcomp> End</testcomp>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.find('.nested-component').exists()).toBe(true)
expect(wrapper.html()).toContain('Outer')
expect(wrapper.html()).toContain('Nested')
expect(wrapper.html()).toContain('End')
})
it('should render deeply nested custom components', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: `
<testcomp data-type="level1">
Level 1
<nestedcomp>
Level 2
<testcomp data-type="level3">Level 3</testcomp>
</nestedcomp>
</testcomp>
`,
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.findAll('.test-component').length).toBeGreaterThan(0)
expect(wrapper.findAll('.nested-component').length).toBeGreaterThan(0)
})
it('should render mixed standard HTML and custom components', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<div class="standard"><testcomp data-type="mixed">Component</testcomp><p>Paragraph</p></div>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.standard').exists()).toBe(true)
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.find('p').exists()).toBe(true)
})
it('should use v-html when no custom components are present', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<div class="standard">Pure HTML</div>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.standard').exists()).toBe(true)
expect(wrapper.html()).toContain('Pure HTML')
})
it('should sanitize raw HTML fallback content in blocks', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<div><img src="x" onerror="alert(1)"><a href="javascript:alert(1)" title="ok">Link</a><script>alert(1)</script></div>',
loading: false,
},
customId: testId,
},
})
await nextTick()
const img = wrapper.find('img')
const link = wrapper.find('a')
expect(img.exists()).toBe(true)
expect(img.attributes('onerror')).toBeUndefined()
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toBeUndefined()
expect(link.attributes('title')).toBe('ok')
expect(wrapper.html()).not.toContain('<script')
expect(wrapper.html()).not.toContain('alert(1)')
})
it('should pass props correctly to custom components', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<testcomp data-type="test" active="true">With Props</testcomp>',
loading: false,
},
customId: testId,
},
})
const comp = wrapper.find('.test-component')
expect(comp.attributes('data-type')).toBe('test')
expect(comp.attributes('data-active')).toBe('true')
})
it('should sanitize dangerous attributes', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<testcomp onclick="alert(1)" data-type="safe">Content</testcomp>',
loading: false,
},
customId: testId,
},
})
const comp = wrapper.find('.test-component')
expect(comp.attributes('onclick')).toBeUndefined()
expect(comp.attributes('data-type')).toBe('safe')
})
it('should handle the playground test scenario', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: `
<testcomp data-type="block-level" style="display: block;">
<nestedcomp>
This is nested
</nestedcomp>
</testcomp>
`,
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.find('.nested-component').exists()).toBe(true)
expect(wrapper.html()).toContain('This is nested')
})
it('should render placeholder when loading is true and not deferred', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<testcomp>Content</testcomp>',
loading: true,
},
customId: testId,
},
})
// Should show placeholder initially
expect(wrapper.find('.html-block-node__placeholder').exists()).toBe(true)
})
})
describe('htmlInlineNode - Custom Components Integration', () => {
const testId = 'test-html-inline'
beforeEach(() => {
setCustomComponents(testId, {
testcomp: TestComponent,
nestedcomp: NestedComponent,
})
})
it('should render custom component inline', () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: '<testcomp data-type="inline">Content</testcomp>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.html()).toContain('Content')
})
it('should render nested custom components inline', () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: 'Text <testcomp data-type="outer"><nestedcomp>Nested</nestedcomp></testcomp> more',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.find('.nested-component').exists()).toBe(true)
expect(wrapper.html()).toContain('Text')
expect(wrapper.html()).toContain('more')
})
it('should use DOM rendering for pure HTML', async () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: '<span class="standard">Pure HTML</span>',
loading: false,
},
customId: testId,
},
})
await nextTick()
expect(wrapper.find('.html-inline-node').exists()).toBe(true)
expect(wrapper.find('.standard').exists()).toBe(true)
expect(wrapper.find('.standard').text()).toBe('Pure HTML')
})
it('should sanitize raw HTML fallback content inline', async () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: 'Before <img src="x" onerror="alert(1)"><a href="javascript:alert(1)" title="ok">Link</a> After',
loading: false,
},
customId: testId,
},
})
await nextTick()
const img = wrapper.find('img')
const link = wrapper.find('a')
expect(img.exists()).toBe(true)
expect(img.attributes('onerror')).toBeUndefined()
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toBeUndefined()
expect(link.attributes('title')).toBe('ok')
expect(wrapper.html()).not.toContain('alert(1)')
})
it('should handle mixed inline content', () => {
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: 'Before <testcomp data-type="test">Component</testcomp> After',
loading: false,
},
customId: testId,
},
})
expect(wrapper.find('.test-component').exists()).toBe(true)
expect(wrapper.html()).toContain('Before')
expect(wrapper.html()).toContain('After')
})
})
describe('component Behavior', () => {
const testId = 'test-behavior'
beforeEach(() => {
setCustomComponents(testId, {
testcomp: TestComponent,
})
})
it('should update when content changes', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '<div>Old</div>',
loading: false,
},
customId: testId,
},
})
expect(wrapper.html()).toContain('Old')
await wrapper.setProps({
node: {
content: '<testcomp data-type="new">New</testcomp>',
loading: false,
},
})
await nextTick()
expect(wrapper.html()).toContain('New')
})
it('should react to custom component registration after mount (inline)', async () => {
const dynamicId = 'test-dynamic-registration-inline'
const wrapper = mount(HtmlInlineNode, {
props: {
node: {
type: 'html_inline',
content: '<testcomp data-type="dynamic">Content</testcomp>',
loading: false,
},
customId: dynamicId,
},
})
await nextTick()
expect(wrapper.find('.test-component').exists()).toBe(false)
setCustomComponents(dynamicId, { testcomp: TestComponent })
await nextTick()
expect(wrapper.find('.test-component').exists()).toBe(true)
})
it('should handle empty content', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: '',
loading: false,
},
customId: testId,
},
})
// Empty content should render empty container
expect(wrapper.find('.html-block-node').exists()).toBe(true)
})
it('should handle content with only whitespace', () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
content: ' \n\t ',
loading: false,
},
customId: testId,
},
})
// Whitespace-only content should still render container
expect(wrapper.find('.html-block-node').exists()).toBe(true)
})
it('renders markdown children inside structured html blocks without duplicating leaked nodes', async () => {
const markdown = `<span style="font-size: 12px;">
🗺️【环境状态】
- 地点:石溪村,李东的茅屋
- 时间:4/12 周四 上午07:00
🎯【选项】
1. 去田里劳作,争取多收成些粮食卖钱
2. 上山检查之前设置的陷阱,看有没有捕到猎
</span>`
const wrapper = mount(MarkdownRender, {
props: {
content: markdown,
batchRendering: false,
deferNodesUntilVisible: false,
},
})
await flushAll()
await nextTick()
const htmlBlock = wrapper.find('.html-block-node')
expect(htmlBlock.exists()).toBe(true)
expect(htmlBlock.element.tagName).toBe('SPAN')
expect(htmlBlock.attributes('style')).toContain('font-size: 12px;')
expect(wrapper.findAll('ul')).toHaveLength(1)
expect(wrapper.findAll('ol')).toHaveLength(1)
const text = wrapper.text()
expect(text.match(/地点:石溪村,李东的茅屋/g)?.length ?? 0).toBe(1)
expect(text.match(/去田里劳作,争取多收成些粮食卖钱/g)?.length ?? 0).toBe(1)
})
it('sanitizes dangerous attrs on structured html wrapper roots', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
tag: 'a',
content: '<a href="javascript:alert(1)" onclick="alert(1)" data-safe="ok"></a>',
attrs: [
['href', 'javascript:alert(1)'],
['onclick', 'alert(1)'],
['data-safe', 'ok'],
],
children: [
{
type: 'paragraph',
raw: 'safe child',
children: [{ type: 'text', raw: 'safe child', content: 'safe child' }],
},
],
loading: false,
},
customId: testId,
},
})
await flushAll()
await nextTick()
const root = wrapper.find('.html-block-node')
expect(root.element.tagName).toBe('A')
expect(root.attributes('data-safe')).toBe('ok')
expect(root.attributes('href')).toBeUndefined()
expect(root.attributes('onclick')).toBeUndefined()
expect(wrapper.text()).toContain('safe child')
})
it('does not treat blocked html tags as structured wrapper nodes', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
tag: 'script',
content: `<script>
- alpha
</script>`,
children: [
{
type: 'list',
raw: '',
ordered: false,
items: [
{
type: 'list_item',
raw: '',
children: [
{
type: 'paragraph',
raw: '',
children: [{ type: 'text', raw: '', content: 'alpha' }],
},
],
},
],
},
],
loading: false,
},
customId: testId,
},
})
await nextTick()
expect(wrapper.findAll('ul')).toHaveLength(0)
expect(wrapper.findAll('li')).toHaveLength(0)
})
it('does not treat literal-content html tags as structured wrapper nodes', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
tag: 'pre',
content: `<pre>
- alpha
</pre>`,
children: [
{
type: 'list',
raw: '',
ordered: false,
items: [
{
type: 'list_item',
raw: '',
children: [
{
type: 'paragraph',
raw: '',
children: [{ type: 'text', raw: '', content: 'alpha' }],
},
],
},
],
},
],
loading: false,
},
customId: testId,
},
})
await nextTick()
expect(wrapper.findAll('ul')).toHaveLength(0)
expect(wrapper.findAll('li')).toHaveLength(0)
expect(wrapper.html()).toContain('<pre>')
expect(wrapper.text()).toContain('- alpha')
})
it('renders details summary as the first direct child for issue #397 content', async () => {
const markdown = `# Structural Stress
> 这个样例用于检查复杂结构在 streaming 中是否抖动、错位或丢节点。
## 列表
1. 第一层
- 第二层
- 第三层
2. 继续
## 表格
| Framework | Route | Purpose |
| --- | --- | --- |
| Vue 3 | \`/test\` | 主调试台 |
| Vue 2 | \`/test\` | 兼容回归 |
| React | \`/test\` | 跨框架对照 |
| Angular | \`/test\` | baseline 对照 |
## HTML
<details>
<summary>展开看一段 HTML</summary>
<p>如果这里的结构错了,通常说明 HTML block / inline 的边界处理有问题。</p>
</details>
## 长段落
Markstream 现在不仅要处理单次完整渲染,还要处理 AI 场景下不断追加的 markdown 内容,所以这个页面更像一个回归驾驶舱。你可以一边编辑左侧输入,一边切换 Vue 2、React 或 Angular 的 test page,用同一段内容观察差异,判断问题是解析层、组件层,还是框架适配层。`
const wrapper = mount(MarkdownRender, {
props: {
content: markdown,
final: true,
batchRendering: false,
deferNodesUntilVisible: false,
typewriter: false,
},
global: {
stubs: {
transition: false,
},
},
})
await flushAll()
const details = wrapper.find('details.html-block-node')
expect(details.exists()).toBe(true)
expect(details.element.firstElementChild?.tagName).toBe('SUMMARY')
expect(details.find('summary').text()).toBe('展开看一段 HTML')
expect(wrapper.text()).not.toContain('<summary>')
expect(wrapper.text()).not.toContain('</details>')
})
it('keeps nested details summaries as the first direct child of each details node', async () => {
const wrapper = mount(HtmlBlockNode, {
props: {
node: {
tag: 'details',
content: '<details open><summary>outer</summary><p>outer body</p><details open><summary>inner</summary><p>inner body</p></details></details>',
attrs: [['open', '']],
children: [
{
type: 'html_block',
tag: 'summary',
content: '<summary>outer</summary>',
children: [
{
type: 'paragraph',
raw: 'outer',
children: [{ type: 'text', raw: 'outer', content: 'outer' }],
},
],
},
{
type: 'paragraph',
raw: 'outer body',
children: [{ type: 'text', raw: 'outer body', content: 'outer body' }],
},
{
type: 'html_block',
tag: 'details',
content: '<details open><summary>inner</summary><p>inner body</p></details>',
attrs: [['open', '']],
children: [
{
type: 'html_block',
tag: 'summary',
content: '<summary>inner</summary>',
children: [
{
type: 'paragraph',
raw: 'inner',
children: [{ type: 'text', raw: 'inner', content: 'inner' }],
},
],
},
{
type: 'paragraph',
raw: 'inner body',
children: [{ type: 'text', raw: 'inner body', content: 'inner body' }],
},
],
},
],
loading: false,
},
},
global: {
stubs: {
transition: false,
},
},
})
await flushAll()
const detailsNodes = wrapper.findAll('details.html-block-node')
expect(detailsNodes).toHaveLength(2)
expect(detailsNodes[0].element.firstElementChild?.tagName).toBe('SUMMARY')
expect(detailsNodes[1].element.firstElementChild?.tagName).toBe('SUMMARY')
expect(detailsNodes[0].find('summary').text()).toBe('outer')
expect(detailsNodes[1].find('summary').text()).toBe('inner')
})
})
@@ -0,0 +1,28 @@
import type { ComputedRef } from 'vue'
import type { CodeBlockMonacoOptions } from '../types/component-props'
import type { NodeRendererCodeBlockProps } from '../types/node-renderer-props'
import { inject, provide } from 'vue'
export interface MarkstreamRendererContext {
customId: ComputedRef<string | undefined>
isDark: ComputedRef<boolean | undefined>
themes: ComputedRef<string[] | undefined>
renderCodeBlocksAsPre: ComputedRef<boolean | undefined>
codeBlockStream: ComputedRef<boolean | undefined>
codeBlockDarkTheme: ComputedRef<any | undefined>
codeBlockLightTheme: ComputedRef<any | undefined>
codeBlockMonacoOptions: ComputedRef<CodeBlockMonacoOptions | undefined>
codeBlockMinWidth: ComputedRef<string | number | undefined>
codeBlockMaxWidth: ComputedRef<string | number | undefined>
codeBlockProps: ComputedRef<NodeRendererCodeBlockProps | undefined>
}
export const MARKSTREAM_RENDERER_CONTEXT = Symbol('MarkstreamRendererContext')
export function provideMarkstreamRendererContext(ctx: MarkstreamRendererContext) {
provide(MARKSTREAM_RENDERER_CONTEXT, ctx)
}
export function useMarkstreamRendererContext() {
return inject<MarkstreamRendererContext | null>(MARKSTREAM_RENDERER_CONTEXT, null)
}
@@ -0,0 +1,18 @@
import { readonly, shallowRef } from 'vue'
import { getKatex } from '../components/MathInlineNode/katex'
const katexReadyState = shallowRef(false)
let initPromise: Promise<void> | null = null
export function useKatexReady() {
if (!initPromise) {
initPromise = getKatex()
.then((k) => {
katexReadyState.value = !!k
})
.catch(() => {
katexReadyState.value = false
})
}
return readonly(katexReadyState)
}
@@ -0,0 +1,116 @@
import { getCurrentInstance } from 'vue'
function humanizeKey(key: string) {
const s = key.split('.').pop() || key
return s
.replace(/[_-]/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/\s+/g, ' ')
.replace(/\b\w/g, c => c.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.minimize': 'Minimize',
'common.zoomIn': 'Zoom in',
'common.zoomOut': 'Zoom out',
'common.resetZoom': 'Reset zoom',
'image.loadError': 'Image failed to load',
'image.loading': 'Loading image...',
}
/**
* Replace default fallback translations.
* Consumers can call this to provide their own fallback translations (e.g. Chinese).
*/
export function setDefaultI18nMap(map: Partial<Record<keyof typeof defaultMap, string>>) {
Object.assign(defaultMap, map)
}
interface Translator {
t: (key: string) => string
te?: (key: string) => boolean
}
function resolveComponentTranslator() {
try {
const instance = getCurrentInstance()
const proxy = instance?.proxy as unknown as Record<string, unknown> | null | undefined
const proxyT = proxy?.$t
if (typeof proxyT === 'function') {
const proxyTe = proxy?.$te
return {
t: proxyT.bind(proxy),
te: typeof proxyTe === 'function' ? proxyTe.bind(proxy) : undefined,
}
}
const globalProperties = instance?.appContext?.config?.globalProperties as Record<string, unknown> | undefined
const appT = globalProperties?.$t
if (typeof appT === 'function') {
const appTe = globalProperties?.$te
return {
t: appT.bind(globalProperties),
te: typeof appTe === 'function' ? appTe.bind(globalProperties) : undefined,
}
}
}
catch {}
return null
}
const fallbackT = (key: string) => defaultMap[key] ?? humanizeKey(key)
function withFallback(translator: Translator) {
return {
t(key: string) {
if (translator.te && defaultMap[key] && !translator.te(key))
return fallbackT(key)
const translated = translator.t(key)
return translated === key && defaultMap[key] ? fallbackT(key) : translated
},
}
}
export function useSafeI18n() {
const translator = resolveComponentTranslator()
if (translator)
return withFallback(translator)
// Synchronous fallback in case `vue-i18n` is not installed.
try {
// Try to use global installed vue-i18n if available synchronously via composition API
// This will work when the consumer has set up vue-i18n and the bundler left
// the runtime entry available. We access it via (global) require-ish path.
// Keep this non-throwing.
const possible = (globalThis as any).$vueI18nUse || null
if (possible && typeof possible === 'function') {
try {
const i18n = possible()
if (i18n && typeof i18n.t === 'function') {
return withFallback({
t: (i18n.t as any).bind(i18n),
te: typeof i18n.te === 'function' ? (i18n.te as any).bind(i18n) : undefined,
})
}
}
catch {}
}
}
catch {}
return { t: fallbackT }
}
@@ -0,0 +1,120 @@
import { createApp, h, ref } from 'vue'
import Tooltip from '../components/Tooltip/Tooltip.vue'
const visible = ref(false)
const content = ref('')
const placement = ref<'top' | 'bottom' | 'left' | 'right'>('top')
const anchorEl = ref<HTMLElement | null>(null)
const tooltipId = ref<string | null>(null)
const originX = ref<number | null>(null)
const originY = ref<number | null>(null)
const tooltipIsDark = ref<boolean | null>(null)
let showTimer: ReturnType<typeof setTimeout> | null = null
let hideTimer: ReturnType<typeof setTimeout> | null = null
function clearTimers() {
if (showTimer) {
clearTimeout(showTimer)
showTimer = null
}
if (hideTimer) {
clearTimeout(hideTimer)
hideTimer = null
}
}
// Mount singleton Tooltip once
let mounted = false
function ensureMounted() {
if (mounted)
return
if (typeof document === 'undefined')
return
try {
mounted = true
const container = document.createElement('div')
container.setAttribute('data-singleton-tooltip', '1')
document.body.appendChild(container)
const App = {
setup() {
return () => h(Tooltip as any, {
'visible': visible.value,
'anchor-el': anchorEl.value,
'content': content.value,
'placement': placement.value,
'id': tooltipId.value,
'originX': originX.value,
'originY': originY.value,
'isDark': tooltipIsDark.value ?? undefined,
})
},
}
createApp(App).mount(container)
}
catch (err) {
mounted = false
console.warn('[markstream-vue] Failed to mount Tooltip component. Tooltips will be disabled.', err)
}
}
export function showTooltipForAnchor(
el: HTMLElement | null,
text: string,
place: typeof placement.value = 'top',
immediate = false,
origin?: { x: number, y: number } | undefined,
isDark?: boolean | null,
) {
if (!el)
return
ensureMounted()
clearTimers()
const doShow = () => {
tooltipId.value = `tooltip-${Date.now()}-${Math.floor(Math.random() * 1000)}`
anchorEl.value = el
content.value = text
placement.value = place
// expose origin coordinates for initial animation
originX.value = origin?.x ?? null
originY.value = origin?.y ?? null
// allow caller to hint dark mode; if omitted, remain null (tooltip will detect global)
tooltipIsDark.value = typeof isDark === 'boolean' ? isDark : null
visible.value = true
try {
el.setAttribute('aria-describedby', tooltipId.value!)
}
catch {}
}
if (immediate)
doShow()
else showTimer = setTimeout(doShow, 80)
}
export function hideTooltip(immediate = false) {
clearTimers()
const doHide = () => {
if (anchorEl.value && tooltipId.value) {
try {
anchorEl.value.removeAttribute('aria-describedby')
}
catch {}
}
visible.value = false
anchorEl.value = null
tooltipId.value = null
originX.value = null
originY.value = null
}
if (immediate)
doHide()
else hideTimer = setTimeout(doHide, 120)
}
export default {
showTooltipForAnchor,
hideTooltip,
}
@@ -0,0 +1,330 @@
import type { Ref } from 'vue'
import { inject, provide, ref } from 'vue'
// Injection key for viewport-priority registration
const ViewportPriorityKey = Symbol('ViewportPriority') as unknown as InjectionKey<RegisterFn>
export interface VisibilityHandle {
isVisible: Ref<boolean>
whenVisible: Promise<void>
destroy: () => void
}
export type RegisterFn = (el: HTMLElement, opts?: { rootMargin?: string, threshold?: number }) => VisibilityHandle
type InjectionKey<T> = symbol & { __type?: T }
interface IdleDeadlineLike {
didTimeout: boolean
timeRemaining: () => number
}
/**
* Provide a shared viewport-priority registrar.
* Targets resolve immediately when they enter the viewport and otherwise
* trickle through during browser idle so heavy nodes can prerender offscreen.
*/
export function provideViewportPriority(
getRootEl: (target?: HTMLElement | null) => HTMLElement | null | undefined,
enabled: Ref<boolean> | boolean,
): RegisterFn {
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined'
const enabledRef = typeof enabled === 'boolean' ? ref(enabled) : enabled
const requestIdle = isBrowser
? ((window as any).requestIdleCallback as ((cb: (deadline: IdleDeadlineLike) => void, opts?: { timeout?: number }) => number) | undefined)
?? ((cb: (deadline: IdleDeadlineLike) => void) => window.setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), 16))
: null
const cancelIdle = isBrowser
? ((window as any).cancelIdleCallback as ((id: number) => void) | undefined)
?? ((id: number) => window.clearTimeout(id))
: null
interface ObserverConfig {
root: HTMLElement | null
rootMargin: string
threshold: number
}
// Lazily created IO bound to the provided root element
let io: IntersectionObserver | null = null
let currentConfig: ObserverConfig | null = null
const targets = new Map<Element, { resolve: () => void, visible: Ref<boolean> }>()
const idleQueue = new Set<Element>()
let idleJob: number | null = null
function normalizeConfig(target?: HTMLElement, opts?: { rootMargin?: string, threshold?: number }): ObserverConfig {
return {
root: getRootEl?.(target ?? null) ?? null,
rootMargin: opts?.rootMargin ?? '300px',
threshold: opts?.threshold ?? 0,
}
}
function sameConfig(a: ObserverConfig | null, b: ObserverConfig) {
return !!a
&& a.root === b.root
&& a.rootMargin === b.rootMargin
&& a.threshold === b.threshold
}
function clearIdleJob() {
if (idleJob == null)
return
try {
cancelIdle?.(idleJob)
}
catch {}
idleJob = null
}
function cleanupObserver() {
if (targets.size)
return
try {
io?.disconnect()
}
catch {}
io = null
currentConfig = null
if (!idleQueue.size)
clearIdleJob()
}
function settleTarget(target: Element) {
const data = targets.get(target)
if (!data)
return
if (!data.visible.value) {
data.visible.value = true
try {
data.resolve()
}
catch {}
}
try {
io?.unobserve(target)
}
catch {}
targets.delete(target)
idleQueue.delete(target)
cleanupObserver()
}
function scheduleIdleDrain() {
if (!requestIdle || idleJob != null || !idleQueue.size)
return
idleJob = requestIdle(() => {
idleJob = null
const next = idleQueue.values().next().value as Element | undefined
if (!next)
return
idleQueue.delete(next)
settleTarget(next)
if (idleQueue.size)
scheduleIdleDrain()
}, { timeout: 1200 })
}
function ensureObserver(target?: HTMLElement, opts?: { rootMargin?: string, threshold?: number }) {
if (!isBrowser)
return io
// Guard: some browser-like environments (e.g., jsdom) don't provide IO
if (typeof IntersectionObserver === 'undefined')
return null
const nextConfig = normalizeConfig(target, opts)
if (io && sameConfig(currentConfig, nextConfig))
return io
if (io) {
try {
io.disconnect()
}
catch {}
}
io = new IntersectionObserver((entries) => {
for (const entry of entries) {
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0
if (isVisible)
settleTarget(entry.target)
}
}, {
root: nextConfig.root,
rootMargin: nextConfig.rootMargin, // prefetch slightly before entering viewport
threshold: nextConfig.threshold,
})
currentConfig = nextConfig
for (const el of targets.keys())
io.observe(el)
return io
}
const register: RegisterFn = (el, opts) => {
const visible = ref(false)
let settled = false
let resolve!: () => void
const whenVisible = new Promise<void>((res) => {
resolve = () => {
if (!settled) {
settled = true
res()
}
}
})
const cleanup = () => {
try {
io?.unobserve(el)
}
catch {}
targets.delete(el)
idleQueue.delete(el)
cleanupObserver()
}
if (!isBrowser || !enabledRef.value) {
visible.value = true
resolve()
return { isVisible: visible, whenVisible, destroy: cleanup }
}
const obs = ensureObserver(el, opts)
if (!obs) {
visible.value = true
resolve()
return { isVisible: visible, whenVisible, destroy: cleanup }
}
targets.set(el, { resolve, visible })
obs.observe(el)
idleQueue.add(el)
scheduleIdleDrain()
return { isVisible: visible, whenVisible, destroy: cleanup }
}
provide(ViewportPriorityKey, register)
return register
}
/**
* Child components call this to register an element and await priority.
* If provider is missing, returns a no-op registrar that resolves immediately.
*/
export function useViewportPriority() {
const injected = inject<RegisterFn | undefined>(ViewportPriorityKey, undefined)
if (injected)
return injected
const localTargets = new WeakMap<Element, { resolve: () => void, visible: Ref<boolean> }>()
let localIo: IntersectionObserver | null = null
const localIdleQueue = new Set<Element>()
let localIdleJob: number | null = null
const requestIdle = typeof window !== 'undefined'
? ((window as any).requestIdleCallback as ((cb: (deadline: IdleDeadlineLike) => void, opts?: { timeout?: number }) => number) | undefined)
?? ((cb: (deadline: IdleDeadlineLike) => void) => window.setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), 16))
: null
const cancelIdle = typeof window !== 'undefined'
? ((window as any).cancelIdleCallback as ((id: number) => void) | undefined)
?? ((id: number) => window.clearTimeout(id))
: null
const clearLocalIdleJob = () => {
if (localIdleJob == null)
return
try {
cancelIdle?.(localIdleJob)
}
catch {}
localIdleJob = null
}
const settleLocalTarget = (target: Element) => {
const data = localTargets.get(target)
if (!data)
return
if (!data.visible.value) {
data.visible.value = true
try {
data.resolve()
}
catch {}
}
try {
localIo?.unobserve(target)
}
catch {}
localTargets.delete(target)
localIdleQueue.delete(target)
if (!localIdleQueue.size)
clearLocalIdleJob()
}
const scheduleLocalIdleDrain = () => {
if (!requestIdle || localIdleJob != null || !localIdleQueue.size)
return
localIdleJob = requestIdle(() => {
localIdleJob = null
const next = localIdleQueue.values().next().value as Element | undefined
if (!next)
return
localIdleQueue.delete(next)
settleLocalTarget(next)
if (localIdleQueue.size)
scheduleLocalIdleDrain()
}, { timeout: 1200 })
}
const ensureLocal = () => {
if (localIo)
return localIo
if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined')
return null
localIo = new IntersectionObserver((entries) => {
for (const e of entries) {
const vis = e.isIntersecting || e.intersectionRatio > 0
if (vis)
settleLocalTarget(e.target)
}
}, { root: null, rootMargin: '300px', threshold: 0 })
return localIo
}
const register: RegisterFn = (el) => {
const isVisible = ref(false)
let settled = false
let resolve!: () => void
const whenVisible = new Promise<void>((res) => {
resolve = () => {
if (!settled) {
settled = true
res()
}
}
})
const cleanup = () => {
try {
localIo?.unobserve(el)
}
catch {}
localTargets.delete(el)
localIdleQueue.delete(el)
if (!localIdleQueue.size)
clearLocalIdleJob()
}
const obs = ensureLocal()
if (!obs) {
isVisible.value = true
resolve()
return { isVisible, whenVisible, destroy: cleanup }
}
localTargets.set(el, { resolve, visible: isVisible })
obs.observe(el)
localIdleQueue.add(el)
scheduleLocalIdleDrain()
return { isVisible, whenVisible, destroy: cleanup }
}
return register
}

Some files were not shown because too many files have changed in this diff Show More