forked from boos/BoosAPI
feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,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
|
||||
+53
@@ -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
|
||||
+924
@@ -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
|
||||
+766
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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
Reference in New Issue
Block a user