1321 lines
48 KiB
TypeScript
1321 lines
48 KiB
TypeScript
import React from 'react';
|
|
import morphdom from 'morphdom';
|
|
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
|
|
import type { Part } from '@opencode-ai/sdk/v2';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { openExternalUrl } from '@/lib/url';
|
|
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
|
import type { Theme } from '@/types/theme';
|
|
import { openAppLinkWithConfirmation } from './appLinkConfirmation';
|
|
import { attachAppLinkInteractions } from './appLinkInteractions';
|
|
import type { ToolPopupContent } from './message/types';
|
|
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import type { EditorAPI } from '@/lib/api/types';
|
|
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
|
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
|
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
|
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
|
import {
|
|
getCachedMarkdownBlocks,
|
|
renderMarkdownBlocks,
|
|
renderMarkdownSync,
|
|
type MarkdownImageMode,
|
|
} from './markdown/markdownCore';
|
|
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
|
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
|
import {
|
|
attachMarkdownInteractions,
|
|
applyMarkdownCodeBlockWrapState,
|
|
decorateMarkdown,
|
|
getMarkdownCodeText,
|
|
type DecorateContext,
|
|
type DecorateLabels,
|
|
type MermaidControlOptions,
|
|
type MermaidRender,
|
|
} from './markdown/decorate';
|
|
import { findTextPosition } from './markdown/textPosition';
|
|
import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer';
|
|
import {
|
|
BLOCK_PATH_TOKEN_RE,
|
|
isAbsoluteReferencePath,
|
|
localPathFromFileUrl,
|
|
normalizeReferencePath,
|
|
parseFileReference,
|
|
type ParsedFileReference,
|
|
} from './fileReferenceParser';
|
|
import { fileReferenceExists } from './fileReferenceStat';
|
|
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
|
import { detachedMarkdownDomCache, type DetachedMarkdownDomKey } from './markdown/detachedMarkdownDomCache';
|
|
import { TimelineRevealGateContext } from './timelineRevealGate';
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
|
|
|
const useCurrentMermaidTheme = () => {
|
|
const themeSystem = useOptionalThemeSystem();
|
|
const fallbackLight = getDefaultTheme(false);
|
|
const fallbackDark = getDefaultTheme(true);
|
|
|
|
return themeSystem?.currentTheme
|
|
?? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
? fallbackDark
|
|
: fallbackLight);
|
|
};
|
|
|
|
const useLinkInteractions = ({
|
|
containerRef,
|
|
enabled,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
enabled?: boolean;
|
|
}) => {
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
return attachAppLinkInteractions(container, {
|
|
allowExternalHttp: enabled !== false,
|
|
openAppLink: (href) => void openAppLinkWithConfirmation(href),
|
|
openExternalHttp: (href) => void openExternalUrl(href),
|
|
});
|
|
}, [containerRef, enabled]);
|
|
};
|
|
|
|
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
|
|
download: true,
|
|
copy: true,
|
|
showPanZoomControls: true,
|
|
};
|
|
const DEFAULT_MERMAID_FULLSCREEN_ENABLED = true;
|
|
|
|
const stripLeadingFrontmatter = (markdown: string): string => {
|
|
const frontmatterMatch = markdown.match(
|
|
/^(?:\uFEFF)?(---|\+\+\+)[^\S\r\n]*\r?\n[\s\S]*?\r?\n\1[^\S\r\n]*(?:\r?\n|$)/,
|
|
);
|
|
|
|
if (!frontmatterMatch) {
|
|
return markdown;
|
|
}
|
|
|
|
return markdown.slice(frontmatterMatch[0].length);
|
|
};
|
|
|
|
export type MarkdownVariant = 'assistant' | 'tool' | 'reasoning';
|
|
|
|
interface MarkdownRendererProps {
|
|
content: string;
|
|
part?: Part;
|
|
messageId: string;
|
|
isAnimated?: boolean;
|
|
skipFadeIn?: boolean;
|
|
className?: string;
|
|
isStreaming?: boolean;
|
|
disableStreamAnimation?: boolean;
|
|
variant?: MarkdownVariant;
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
enableFileReferences?: boolean;
|
|
}
|
|
|
|
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
|
const BLOCK_PATH_TOKEN_ATTR = 'data-openchamber-block-path-token';
|
|
const BLOCK_PATH_TOKEN_SELECTOR = `[${BLOCK_PATH_TOKEN_ATTR}]`;
|
|
const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
|
|
// Matches `path[:line[:col]]` or `path:start-end` inside shell/grep-style
|
|
// output. The regex is defined in `./fileReferenceParser`; the inline-code
|
|
// pipeline reads full text content rather than using this regex.
|
|
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
|
|
const FILE_REFERENCE_LINK_LIMIT = 80;
|
|
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
|
|
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
|
|
|
|
const getFileReferenceLinkLimit = (): number => (
|
|
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
|
|
);
|
|
|
|
const KNOWN_FILE_BASENAMES = new Set([
|
|
'dockerfile',
|
|
'makefile',
|
|
'readme',
|
|
'license',
|
|
'.env',
|
|
'.gitignore',
|
|
'.npmrc',
|
|
]);
|
|
|
|
const normalizePath = (value: string): string => {
|
|
return normalizeReferencePath(value);
|
|
};
|
|
|
|
const isAbsolutePath = (value: string): boolean => {
|
|
return isAbsoluteReferencePath(value);
|
|
};
|
|
|
|
const toAbsolutePath = (basePath: string, targetPath: string): string => {
|
|
return toAbsoluteFilePath(basePath, targetPath);
|
|
};
|
|
|
|
const hasFileExtension = (path: string): boolean => {
|
|
const base = path.split('/').filter(Boolean).pop() ?? '';
|
|
if (!base || base.endsWith('.')) {
|
|
return false;
|
|
}
|
|
return /\.[A-Za-z0-9_-]{1,16}$/.test(base);
|
|
};
|
|
|
|
const isLikelyFilePathValue = (path: string): boolean => {
|
|
if (!path || path.startsWith('--') || path.includes('://')) {
|
|
return false;
|
|
}
|
|
|
|
if (/[<>]/.test(path) || /\s{2,}/.test(path)) {
|
|
return false;
|
|
}
|
|
|
|
const normalized = normalizePath(path);
|
|
const baseName = normalized.split('/').filter(Boolean).pop() ?? normalized;
|
|
if (!baseName || baseName === '.' || baseName === '..') {
|
|
return false;
|
|
}
|
|
|
|
const base = baseName.toLowerCase();
|
|
if (KNOWN_FILE_BASENAMES.has(base) || (base.startsWith('.') && base.length > 1)) {
|
|
return true;
|
|
}
|
|
|
|
return hasFileExtension(normalized);
|
|
};
|
|
|
|
const isLikelyFilePath = (value: string): boolean => {
|
|
const parsed = parseFileReference(value);
|
|
if (!parsed) {
|
|
return false;
|
|
}
|
|
return isLikelyFilePathValue(parsed.path);
|
|
};
|
|
|
|
const unwrapBlockCodePathTokens = (container: HTMLElement): void => {
|
|
const tokenSpans = container.querySelectorAll<HTMLElement>(BLOCK_PATH_TOKEN_SELECTOR);
|
|
for (const span of Array.from(tokenSpans)) {
|
|
span.replaceWith(container.ownerDocument.createTextNode(span.textContent ?? ''));
|
|
}
|
|
|
|
const scannedBlocks = container.querySelectorAll<HTMLElement>(`code[${CODE_BLOCK_PATH_SCANNED_ATTR}]`);
|
|
for (const codeBlock of Array.from(scannedBlocks)) {
|
|
codeBlock.removeAttribute(CODE_BLOCK_PATH_SCANNED_ATTR);
|
|
codeBlock.normalize();
|
|
}
|
|
};
|
|
|
|
const extractPathCandidateFromElement = (element: HTMLElement): string => {
|
|
if (element.tagName.toLowerCase() === 'a') {
|
|
const href = element.getAttribute('href')?.trim();
|
|
const fileUrlPath = href ? localPathFromFileUrl(href) : null;
|
|
if (fileUrlPath) {
|
|
return fileUrlPath;
|
|
}
|
|
if (href && isLikelyFilePath(href)) {
|
|
return href;
|
|
}
|
|
}
|
|
|
|
return (element.textContent || '').trim();
|
|
};
|
|
|
|
// Walks text nodes inside `<pre><code>` subtrees and wraps any substring that
|
|
// looks like a `path[:line[:col]]` reference in a span carrying
|
|
// `data-openchamber-block-path-token`. `annotateFileLinks` then promotes those
|
|
// spans into clickable file links via the same existing pipeline used for
|
|
// inline code (parseFileReference → fileReferenceExists → openFileReference).
|
|
//
|
|
// Idempotent: each `<code>` node is marked with
|
|
// `data-openchamber-block-paths-scanned` once processed so the walk is not
|
|
// repeated on the same element. When the renderer replaces the `<code>` subtree
|
|
// (e.g. on content change during streaming), the new element lacks the marker and
|
|
// will be rescanned on the next mutation-observer callback.
|
|
const wrapBlockCodePathTokens = (container: HTMLElement): void => {
|
|
const codeBlocks = container.querySelectorAll<HTMLElement>('pre code');
|
|
if (codeBlocks.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const doc = container.ownerDocument;
|
|
if (!doc) {
|
|
return;
|
|
}
|
|
|
|
for (const codeBlock of Array.from(codeBlocks)) {
|
|
if (codeBlock.getAttribute(CODE_BLOCK_PATH_SCANNED_ATTR) === 'true') {
|
|
continue;
|
|
}
|
|
|
|
// Skip absurdly large code blocks to keep DOM work bounded.
|
|
if ((codeBlock.textContent ?? '').length > MAX_BLOCK_CODE_SCAN_LENGTH) {
|
|
codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
|
|
continue;
|
|
}
|
|
|
|
const walker = doc.createTreeWalker(codeBlock, NodeFilter.SHOW_TEXT);
|
|
const textNodes: Text[] = [];
|
|
let currentNode = walker.nextNode();
|
|
while (currentNode) {
|
|
const textNode = currentNode as Text;
|
|
if (!textNode.parentElement?.closest('[data-md-code-line-number]')) {
|
|
textNodes.push(textNode);
|
|
}
|
|
currentNode = walker.nextNode();
|
|
}
|
|
|
|
const fullText = getMarkdownCodeText(codeBlock);
|
|
if (!fullText.includes('.')) {
|
|
codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
|
|
continue;
|
|
}
|
|
|
|
BLOCK_PATH_TOKEN_RE.lastIndex = 0;
|
|
const matches: Array<{ start: number; end: number; raw: string }> = [];
|
|
let match: RegExpExecArray | null = BLOCK_PATH_TOKEN_RE.exec(fullText);
|
|
while (match) {
|
|
const raw = match[0];
|
|
if (raw && isLikelyFilePath(raw)) {
|
|
matches.push({ start: match.index, end: match.index + raw.length, raw });
|
|
}
|
|
match = BLOCK_PATH_TOKEN_RE.exec(fullText);
|
|
}
|
|
|
|
for (const { start, end, raw } of matches.reverse()) {
|
|
const startPosition = findTextPosition(textNodes, start, 'right');
|
|
const endPosition = findTextPosition(textNodes, end, 'left');
|
|
if (!startPosition || !endPosition) {
|
|
continue;
|
|
}
|
|
|
|
const range = doc.createRange();
|
|
range.setStart(startPosition.node, startPosition.offset);
|
|
range.setEnd(endPosition.node, endPosition.offset);
|
|
|
|
const span = doc.createElement('span');
|
|
span.setAttribute(BLOCK_PATH_TOKEN_ATTR, 'true');
|
|
span.textContent = raw;
|
|
|
|
range.deleteContents();
|
|
range.insertNode(span);
|
|
}
|
|
|
|
codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
|
|
}
|
|
};
|
|
|
|
const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => {
|
|
const parsed = parseFileReference(rawValue);
|
|
if (!parsed || !isLikelyFilePathValue(parsed.path)) {
|
|
return null;
|
|
}
|
|
|
|
const resolvedPath = isAbsolutePath(parsed.path)
|
|
? normalizePath(parsed.path)
|
|
: toAbsolutePath(effectiveDirectory, parsed.path);
|
|
if (!resolvedPath) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...parsed,
|
|
resolvedPath,
|
|
};
|
|
};
|
|
|
|
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
|
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
|
|
};
|
|
|
|
const useFileReferenceInteractions = ({
|
|
containerRef,
|
|
effectiveDirectory,
|
|
editor,
|
|
preferRuntimeEditor,
|
|
enabled,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
effectiveDirectory: string;
|
|
editor?: EditorAPI;
|
|
preferRuntimeEditor?: boolean;
|
|
enabled: boolean;
|
|
}) => {
|
|
const annotationDebounceRef = React.useRef<number | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
// Wait for the real directory: annotating against an empty/fallback
|
|
// directory issues stat probes under the wrong cache key (and the wrong
|
|
// server directory), and the pass reruns anyway once the directory
|
|
// resolves — every link ended up verified twice.
|
|
if (enabled && !effectiveDirectory) {
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
|
|
// On mobile surfaces, file-reference highlighting is disabled entirely — not
|
|
// just visually. The annotation pass is what issues the filesystem `stat`
|
|
// probes (fileReferenceExists → /api/fs/stat), so skipping it here guarantees
|
|
// no probe requests are ever sent from a mobile runtime.
|
|
const fileReferencesEnabled = enabled && !isMobileSurfaceRuntime();
|
|
|
|
const clearFileLinkAttributes = (candidate: HTMLElement) => {
|
|
candidate.removeAttribute('data-openchamber-file-link');
|
|
candidate.removeAttribute('data-openchamber-file-ref');
|
|
candidate.removeAttribute('data-openchamber-file-path');
|
|
if (candidate.getAttribute('title') === 'Open file') {
|
|
candidate.removeAttribute('title');
|
|
}
|
|
if (candidate.tagName.toLowerCase() !== 'a') {
|
|
candidate.removeAttribute('role');
|
|
candidate.removeAttribute('tabindex');
|
|
}
|
|
};
|
|
|
|
const clearAnnotatedFileLinks = () => {
|
|
const annotated = container.querySelectorAll<HTMLElement>(FILE_LINK_SELECTOR);
|
|
for (const candidate of Array.from(annotated)) {
|
|
clearFileLinkAttributes(candidate);
|
|
}
|
|
unwrapBlockCodePathTokens(container);
|
|
};
|
|
|
|
if (!fileReferencesEnabled) {
|
|
clearAnnotatedFileLinks();
|
|
return;
|
|
}
|
|
|
|
const scheduleAnnotation = (delayMs = 0) => {
|
|
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
|
|
window.clearTimeout(annotationDebounceRef.current);
|
|
}
|
|
if (typeof window === 'undefined') {
|
|
annotateFileLinks();
|
|
return;
|
|
}
|
|
annotationDebounceRef.current = window.setTimeout(() => {
|
|
annotationDebounceRef.current = null;
|
|
window.requestAnimationFrame(() => {
|
|
if (!cancelled) {
|
|
annotateFileLinks();
|
|
}
|
|
});
|
|
}, delayMs);
|
|
};
|
|
|
|
const annotateFileLinks = () => {
|
|
annotationWriteDepth += 1;
|
|
try {
|
|
annotateFileLinksInner();
|
|
} finally {
|
|
// Let the mutation events from our own writes flush before the
|
|
// observer starts listening for real content changes again.
|
|
queueMicrotask(() => {
|
|
annotationWriteDepth -= 1;
|
|
});
|
|
}
|
|
};
|
|
|
|
const annotateFileLinksInner = () => {
|
|
if (fileReferencesEnabled) {
|
|
wrapBlockCodePathTokens(container);
|
|
}
|
|
const candidates = container.querySelectorAll<HTMLElement>(
|
|
`[data-markdown="inline-code"], a, ${BLOCK_PATH_TOKEN_SELECTOR}`,
|
|
);
|
|
let linkedCount = 0;
|
|
|
|
for (const candidate of Array.from(candidates)) {
|
|
const rawCandidate = extractPathCandidateFromElement(candidate);
|
|
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
|
|
clearFileLinkAttributes(candidate);
|
|
|
|
if (!resolved) {
|
|
continue;
|
|
}
|
|
|
|
if (linkedCount >= fileReferenceLinkLimit) {
|
|
continue;
|
|
}
|
|
|
|
linkedCount += 1;
|
|
|
|
const canGrantOutsideFile = isDesktopShell()
|
|
&& isDesktopLocalOriginActive()
|
|
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
|
|
const existsPromise = canGrantOutsideFile
|
|
? Promise.resolve(true)
|
|
: fileReferenceExists(resolved.resolvedPath, effectiveDirectory);
|
|
|
|
void existsPromise.then((exists) => {
|
|
if (cancelled || !exists || !container.contains(candidate)) {
|
|
return;
|
|
}
|
|
|
|
const latestRawCandidate = extractPathCandidateFromElement(candidate);
|
|
const latestResolved = getResolvedReference(latestRawCandidate, effectiveDirectory);
|
|
if (!latestResolved || latestResolved.resolvedPath !== resolved.resolvedPath) {
|
|
return;
|
|
}
|
|
|
|
candidate.setAttribute('data-openchamber-file-link', 'true');
|
|
candidate.setAttribute('data-openchamber-file-ref', latestRawCandidate);
|
|
candidate.setAttribute('data-openchamber-file-path', latestResolved.resolvedPath);
|
|
candidate.setAttribute('title', 'Open file');
|
|
if (candidate.tagName.toLowerCase() !== 'a') {
|
|
candidate.setAttribute('role', 'button');
|
|
candidate.setAttribute('tabindex', '0');
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const openFileReference = async (sourceElement: HTMLElement) => {
|
|
const raw = sourceElement.getAttribute('data-openchamber-file-ref') || extractPathCandidateFromElement(sourceElement);
|
|
const resolved = getResolvedReference(raw, effectiveDirectory);
|
|
if (!resolved) {
|
|
return;
|
|
}
|
|
|
|
const contextDirectory = getContextDirectory(effectiveDirectory, resolved.resolvedPath);
|
|
if (preferRuntimeEditor && editor) {
|
|
void editor.openFile(
|
|
resolved.resolvedPath,
|
|
Number.isFinite(resolved.line ?? Number.NaN)
|
|
? Math.max(1, Math.trunc(resolved.line as number))
|
|
: undefined,
|
|
Number.isFinite(resolved.column ?? Number.NaN)
|
|
? Math.max(1, Math.trunc(resolved.column as number))
|
|
: undefined,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory)) {
|
|
await ensureOutsideFileGrantForDesktop(resolved.resolvedPath, effectiveDirectory);
|
|
}
|
|
|
|
const uiStore = useUIStore.getState();
|
|
if (Number.isFinite(resolved.line ?? Number.NaN)) {
|
|
uiStore.openContextFileAtLine(
|
|
contextDirectory,
|
|
resolved.resolvedPath,
|
|
Math.max(1, Math.trunc(resolved.line as number)),
|
|
Number.isFinite(resolved.column ?? Number.NaN)
|
|
? Math.max(1, Math.trunc(resolved.column as number))
|
|
: 1,
|
|
);
|
|
} else {
|
|
uiStore.openContextFile(contextDirectory, resolved.resolvedPath);
|
|
}
|
|
};
|
|
|
|
const handleClick = (event: MouseEvent) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const fileRefElement = target.closest(FILE_LINK_SELECTOR);
|
|
if (!(fileRefElement instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
void openFileReference(fileRefElement);
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== 'Enter' && event.key !== ' ') {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement) || target.getAttribute('data-openchamber-file-link') !== 'true') {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
void openFileReference(target);
|
|
};
|
|
|
|
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
|
|
|
|
// Our own annotation writes (path-token wrapping, attribute updates) fire
|
|
// childList mutations too; observing them re-ran the whole pass — every
|
|
// link was scanned and verified twice per render.
|
|
let annotationWriteDepth = 0;
|
|
const observer = new MutationObserver(() => {
|
|
if (annotationWriteDepth > 0) return;
|
|
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
|
|
});
|
|
observer.observe(container, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
|
|
container.addEventListener('click', handleClick);
|
|
container.addEventListener('keydown', handleKeyDown);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
|
|
window.clearTimeout(annotationDebounceRef.current);
|
|
}
|
|
annotationDebounceRef.current = null;
|
|
observer.disconnect();
|
|
container.removeEventListener('click', handleClick);
|
|
container.removeEventListener('keydown', handleKeyDown);
|
|
};
|
|
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, enabled]);
|
|
};
|
|
|
|
const useMermaidInlineInteractions = ({
|
|
containerRef,
|
|
onShowPopup,
|
|
enableFullscreen,
|
|
enablePanZoom,
|
|
allowMermaidWheelEvents,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
enableFullscreen?: boolean;
|
|
enablePanZoom?: boolean;
|
|
allowMermaidWheelEvents?: boolean;
|
|
}) => {
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const handleMermaidClick = (event: MouseEvent) => {
|
|
if (!enableFullscreen || !onShowPopup) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
if (target.closest('button, a, [role="button"]')) {
|
|
return;
|
|
}
|
|
|
|
const block = target.closest(MERMAID_BLOCK_SELECTOR);
|
|
if (!block) {
|
|
return;
|
|
}
|
|
|
|
if (block instanceof HTMLElement && block.hasAttribute('data-mermaid-suppress-click')) {
|
|
block.removeAttribute('data-mermaid-suppress-click');
|
|
return;
|
|
}
|
|
|
|
const renderedBlocks = Array.from(container.querySelectorAll<HTMLElement>(MERMAID_BLOCK_SELECTOR));
|
|
const blockIndex = renderedBlocks.indexOf(block as HTMLElement);
|
|
if (blockIndex < 0) {
|
|
return;
|
|
}
|
|
|
|
const source = block instanceof HTMLElement ? block.getAttribute('data-md-source') : null;
|
|
if (!source || source.trim().length === 0) {
|
|
return;
|
|
}
|
|
|
|
const filename = `Diagram ${blockIndex + 1}`;
|
|
onShowPopup({
|
|
open: true,
|
|
title: filename,
|
|
content: '',
|
|
metadata: {
|
|
tool: 'mermaid-preview',
|
|
filename,
|
|
},
|
|
mermaid: {
|
|
url: `data:text/plain;charset=utf-8,${encodeURIComponent(source)}`,
|
|
source,
|
|
filename,
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleInlineWheel = (event: WheelEvent) => {
|
|
if (allowMermaidWheelEvents || ((event.ctrlKey || event.metaKey) && enablePanZoom)) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const block = target.closest(MERMAID_BLOCK_SELECTOR);
|
|
if (!block) {
|
|
return;
|
|
}
|
|
|
|
// Keep regular page scroll while preventing Streamdown inline wheel-zoom handlers.
|
|
event.stopPropagation();
|
|
};
|
|
|
|
container.addEventListener('click', handleMermaidClick);
|
|
container.addEventListener('wheel', handleInlineWheel, { capture: true, passive: true });
|
|
|
|
return () => {
|
|
container.removeEventListener('click', handleMermaidClick);
|
|
container.removeEventListener('wheel', handleInlineWheel, true);
|
|
};
|
|
}, [allowMermaidWheelEvents, containerRef, enableFullscreen, enablePanZoom, onShowPopup]);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rendering core: marked -> math -> shiki -> sanitize -> decorate -> morphdom
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Mermaid layout is expensive; `decorate` would otherwise re-render every
|
|
// diagram on every paced-stream step (~40/sec). Memoize by theme+mode+source
|
|
// so a stable diagram is laid out once and served from cache thereafter.
|
|
const MERMAID_RENDER_CACHE = new Map<string, MermaidRender>();
|
|
const MERMAID_RENDER_CACHE_MAX = 100;
|
|
const MARKDOWN_DECORATION_ID_ATTR = 'data-md-decoration-id';
|
|
|
|
// True when the container already holds exactly these settled blocks with the
|
|
// current decoration. The first paint of a remounted message is served from
|
|
// the block cache; when that paint is already final, the async render would
|
|
// only parse, highlight, sanitize, and morph the same HTML into place again.
|
|
const domMatchesRenderedBlocks = (
|
|
target: HTMLElement,
|
|
blocks: ReadonlyArray<{ id: string }>,
|
|
decorationId: string,
|
|
): boolean => {
|
|
const children = target.children;
|
|
if (children.length !== blocks.length) return false;
|
|
for (let index = 0; index < blocks.length; index += 1) {
|
|
const child = children[index];
|
|
if (
|
|
!child
|
|
|| child.getAttribute('data-md-id') !== blocks[index]?.id
|
|
|| child.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
const MARKDOWN_DECORATION_IDS = new WeakMap<DecorateContext, string>();
|
|
let nextMarkdownDecorationId = 0;
|
|
const MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS = 200_000;
|
|
|
|
const getMarkdownDecorationId = (ctx: DecorateContext): string => {
|
|
const existing = MARKDOWN_DECORATION_IDS.get(ctx);
|
|
if (existing) return existing;
|
|
const id = `decoration-${nextMarkdownDecorationId}`;
|
|
nextMarkdownDecorationId += 1;
|
|
MARKDOWN_DECORATION_IDS.set(ctx, id);
|
|
return id;
|
|
};
|
|
|
|
const cachedMermaidRender = (key: string, compute: () => MermaidRender): MermaidRender => {
|
|
const existing = MERMAID_RENDER_CACHE.get(key);
|
|
if (existing) {
|
|
MERMAID_RENDER_CACHE.delete(key);
|
|
MERMAID_RENDER_CACHE.set(key, existing);
|
|
return existing;
|
|
}
|
|
const value = compute();
|
|
MERMAID_RENDER_CACHE.set(key, value);
|
|
if (MERMAID_RENDER_CACHE.size > MERMAID_RENDER_CACHE_MAX) {
|
|
const oldest = MERMAID_RENDER_CACHE.keys().next().value;
|
|
if (oldest) MERMAID_RENDER_CACHE.delete(oldest);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
const mermaidColorsFromTheme = (theme: Theme) => ({
|
|
bg: theme.colors.surface.elevated,
|
|
fg: theme.colors.surface.foreground,
|
|
line: theme.colors.interactive.border,
|
|
accent: theme.colors.primary.base,
|
|
muted: theme.colors.surface.mutedForeground,
|
|
surface: theme.colors.surface.muted,
|
|
border: theme.colors.interactive.border,
|
|
transparent: true,
|
|
font: 'system-ui, sans-serif',
|
|
});
|
|
|
|
const useDecorateContext = (
|
|
currentTheme: Theme,
|
|
deferCodeLineNumberSync: boolean,
|
|
onPreviewLoopback?: (url: string) => void,
|
|
mermaidControls: MermaidControlOptions = DEFAULT_MERMAID_CONTROLS,
|
|
): DecorateContext => {
|
|
const { t } = useI18n();
|
|
const labels: DecorateLabels = React.useMemo(() => ({
|
|
copy: t('markdownRenderer.code.actions.copyTitle'),
|
|
copied: t('markdownRenderer.code.actions.copiedTitle'),
|
|
enableCodeWrap: t('markdownRenderer.code.actions.enableWrapTitle'),
|
|
disableCodeWrap: t('markdownRenderer.code.actions.disableWrapTitle'),
|
|
copyTable: t('markdownRenderer.table.actions.copyTitle'),
|
|
downloadTable: t('markdownRenderer.table.actions.downloadTitle'),
|
|
copyDiagram: t('markdownRenderer.mermaid.actions.copySourceTitle'),
|
|
downloadDiagram: t('markdownRenderer.mermaid.actions.downloadSvgTitle'),
|
|
zoomInDiagram: t('markdownRenderer.mermaid.actions.zoomInTitle'),
|
|
zoomOutDiagram: t('markdownRenderer.mermaid.actions.zoomOutTitle'),
|
|
resetDiagramView: t('markdownRenderer.mermaid.actions.resetViewTitle'),
|
|
previewLabel: t('terminalView.preview.open'),
|
|
previewTitle: t('terminalView.preview.openTitle'),
|
|
}), [t]);
|
|
|
|
const codeBlockLineWrap = useUIStore((state) => state.codeBlockLineWrap);
|
|
const setCodeBlockLineWrap = useUIStore((state) => state.setCodeBlockLineWrap);
|
|
const toggleCodeBlockLineWrap = React.useCallback(() => {
|
|
setCodeBlockLineWrap(!useUIStore.getState().codeBlockLineWrap);
|
|
}, [setCodeBlockLineWrap]);
|
|
|
|
return React.useMemo<DecorateContext>(() => {
|
|
const colors = mermaidColorsFromTheme(currentTheme);
|
|
const mode = useUIStore.getState().mermaidRenderingMode;
|
|
const themeId = currentTheme.metadata?.id ?? 'theme';
|
|
const renderMermaid = (source: string): MermaidRender =>
|
|
cachedMermaidRender(`${themeId}:${mode}:${source}`, () => {
|
|
try {
|
|
if (mode === 'ascii') return { ascii: renderMermaidASCII(source) };
|
|
return { svg: renderMermaidSVG(source, colors) };
|
|
} catch {
|
|
return {};
|
|
}
|
|
});
|
|
return { labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback };
|
|
}, [currentTheme, labels, mermaidControls, codeBlockLineWrap, deferCodeLineNumberSync, toggleCodeBlockLineWrap, onPreviewLoopback]);
|
|
};
|
|
|
|
// Runs the async render pipeline into the container and keeps a stable
|
|
// delegated interaction listener attached.
|
|
const useMorphdomMarkdown = ({
|
|
containerRef,
|
|
text,
|
|
streaming,
|
|
imageMode = 'inline',
|
|
syntaxVars,
|
|
ctx,
|
|
domCacheKey,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
text: string;
|
|
streaming: boolean;
|
|
imageMode?: MarkdownImageMode;
|
|
syntaxVars: Record<string, string>;
|
|
ctx: DecorateContext;
|
|
domCacheKey?: DetachedMarkdownDomKey | null;
|
|
}) => {
|
|
React.useEffect(() => {
|
|
ensureMarkdownShikiTheme();
|
|
}, []);
|
|
|
|
const mermaidViewerRef = React.useRef<ReturnType<typeof createMermaidViewerRegistry> | null>(null);
|
|
const renderRevisionRef = React.useRef(0);
|
|
// A provisional first paint (blocks not in the settled cache) holds the
|
|
// timeline reveal until the async render lands, so the session opens with
|
|
// final code highlighting instead of a visible restyle.
|
|
const revealGate = React.useContext(TimelineRevealGateContext);
|
|
const releaseRevealHoldRef = React.useRef<(() => void) | null>(null);
|
|
const releaseRevealHold = React.useCallback(() => {
|
|
releaseRevealHoldRef.current?.();
|
|
releaseRevealHoldRef.current = null;
|
|
}, []);
|
|
React.useEffect(() => releaseRevealHold, [releaseRevealHold]);
|
|
// Only DOM that was actually restored or completed by the async pipeline is
|
|
// eligible for capture. A fallback from an earlier content revision is not.
|
|
const mountedDomRef = React.useRef<{
|
|
key: DetachedMarkdownDomKey;
|
|
copiedLabel: string;
|
|
} | null>(null);
|
|
const refreshMermaidViewers = React.useCallback(() => {
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
if (!mermaidViewerRef.current) {
|
|
if (!shouldRefreshMermaidViewers(container)) {
|
|
return;
|
|
}
|
|
mermaidViewerRef.current = createMermaidViewerRegistry(container);
|
|
return;
|
|
}
|
|
mermaidViewerRef.current.refresh();
|
|
}, [containerRef]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
renderRevisionRef.current += 1;
|
|
mountedDomRef.current = null;
|
|
}, [ctx, imageMode, streaming, text]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (!domCacheKey) return;
|
|
const container = containerRef.current;
|
|
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
|
if (!target || target.childNodes.length > 0) return;
|
|
|
|
const cached = detachedMarkdownDomCache.take(domCacheKey);
|
|
if (cached) {
|
|
target.appendChild(cached);
|
|
const decorationId = getMarkdownDecorationId(ctx);
|
|
for (const block of Array.from(target.children)) {
|
|
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
|
}
|
|
for (const [key, value] of Object.entries(syntaxVars)) target.style.setProperty(key, value);
|
|
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
|
|
mountedDomRef.current = {
|
|
key: domCacheKey,
|
|
copiedLabel: ctx.labels.copied,
|
|
};
|
|
streamPerfCount('ui.markdown_renderer.dom_cache.hit');
|
|
}
|
|
}, [containerRef, ctx, domCacheKey, syntaxVars, text.length]);
|
|
|
|
// Restoration follows the cache identity above, but capture must only happen
|
|
// when this renderer lifecycle ends. Combining both in one keyed effect would
|
|
// detach the live DOM on ordinary content, theme, or locale updates.
|
|
React.useLayoutEffect(() => {
|
|
const container = containerRef.current;
|
|
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
|
if (!target) return;
|
|
return () => {
|
|
const mountedDom = mountedDomRef.current;
|
|
if (!mountedDom) return;
|
|
// Viewer controllers and transient interaction state belong to the
|
|
// current renderer instance and must not cross the cache boundary.
|
|
if (target.childNodes.length === 0 || shouldRefreshMermaidViewers(target)) return;
|
|
if (Array.from(target.children).some((block) => !block.hasAttribute('data-md-id'))) return;
|
|
if (target.querySelector('[data-md-copy-pending]')) return;
|
|
const selection = window.getSelection();
|
|
if (selection?.rangeCount && !selection.isCollapsed && selection.getRangeAt(0).intersectsNode(target)) return;
|
|
const openMenu = target.querySelector<HTMLElement>('[data-md-menu]:not(.hidden)');
|
|
const copiedButton = Array.from(target.querySelectorAll<HTMLButtonElement>('[data-md-action]'))
|
|
.some((button) => button.getAttribute('title') === mountedDom.copiedLabel);
|
|
if (openMenu || copiedButton) return;
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
fragment.append(...Array.from(target.childNodes));
|
|
detachedMarkdownDomCache.store({ ...mountedDom.key, fragment });
|
|
streamPerfCount('ui.markdown_renderer.dom_cache.capture');
|
|
};
|
|
}, [containerRef]);
|
|
|
|
// Synchronous first paint: while the async parse is in-flight, show escaped
|
|
// plain text immediately so there is no blank frame on initial mount. Only
|
|
// runs when the target is empty — subsequent updates keep the prior rich DOM
|
|
// until the next async render morphs in (no flash). Mirrors OpenCode's
|
|
// `initialValue: fallback(text)` resource pattern.
|
|
React.useLayoutEffect(() => {
|
|
const container = containerRef.current;
|
|
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
|
if (!target) return;
|
|
const decorationId = getMarkdownDecorationId(ctx);
|
|
if (text && target.childNodes.length === 0) {
|
|
const cachedBlocks = !streaming ? getCachedMarkdownBlocks(text, imageMode) : null;
|
|
if (cachedBlocks) {
|
|
let hasMermaidBlock = false;
|
|
for (const cachedBlock of cachedBlocks) {
|
|
const block = document.createElement('div');
|
|
block.setAttribute('data-md-block', '');
|
|
block.style.display = 'contents';
|
|
block.innerHTML = cachedBlock.html;
|
|
decorateMarkdown(block, ctx);
|
|
block.setAttribute('data-md-id', cachedBlock.id);
|
|
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
|
hasMermaidBlock ||= shouldRefreshMermaidViewers(block);
|
|
target.appendChild(block);
|
|
}
|
|
if (hasMermaidBlock) refreshMermaidViewers();
|
|
} else {
|
|
if (!streaming && !releaseRevealHoldRef.current) {
|
|
releaseRevealHoldRef.current = revealGate?.hold() ?? null;
|
|
}
|
|
const block = document.createElement('div');
|
|
block.setAttribute('data-md-block', '');
|
|
block.style.display = 'contents';
|
|
block.innerHTML = renderMarkdownSync(text, imageMode);
|
|
decorateMarkdown(block, ctx);
|
|
block.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
|
target.appendChild(block);
|
|
if (shouldRefreshMermaidViewers(block)) refreshMermaidViewers();
|
|
}
|
|
} else if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(target)) {
|
|
// StrictMode re-runs this setup after the cleanup probe. The DOM remains,
|
|
// but the viewer registry does not, so recreate it without reinstalling
|
|
// or re-decorating ordinary blocks.
|
|
refreshMermaidViewers();
|
|
}
|
|
}, [containerRef, text, streaming, imageMode, ctx, refreshMermaidViewers, revealGate]);
|
|
|
|
React.useEffect(() => () => {
|
|
mermaidViewerRef.current?.cleanup();
|
|
mermaidViewerRef.current = null;
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
|
let active = true;
|
|
const renderRevision = renderRevisionRef.current;
|
|
const decorationId = getMarkdownDecorationId(ctx);
|
|
|
|
if (!streaming) {
|
|
const cachedBlocks = getCachedMarkdownBlocks(text, imageMode);
|
|
if (cachedBlocks && domMatchesRenderedBlocks(target, cachedBlocks, decorationId)) {
|
|
mountedDomRef.current = domCacheKey
|
|
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
|
|
: null;
|
|
streamPerfCount('ui.markdown_renderer.settled_paint.reused');
|
|
releaseRevealHold();
|
|
return;
|
|
}
|
|
}
|
|
|
|
void renderMarkdownBlocks(text, streaming, imageMode).then((blocks) => {
|
|
if (!active || renderRevisionRef.current !== renderRevision) return;
|
|
const existing = Array.from(target.children) as HTMLElement[];
|
|
|
|
// Reconcile per block: only re-morph blocks whose content changed, leaving
|
|
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
|
|
// to the trailing (growing) block instead of the whole message.
|
|
let enteredThisPass = 0;
|
|
blocks.forEach((block, index) => {
|
|
let el = existing[index];
|
|
let isNewBlock = false;
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.setAttribute('data-md-block', '');
|
|
el.style.display = 'contents';
|
|
target.appendChild(el);
|
|
isNewBlock = true;
|
|
}
|
|
if (el.getAttribute('data-md-id') === block.id) {
|
|
if (el.getAttribute(MARKDOWN_DECORATION_ID_ATTR) !== decorationId) {
|
|
const hasMermaidBlock = shouldRefreshMermaidViewers(el);
|
|
if (hasMermaidBlock) {
|
|
mermaidViewerRef.current?.cleanup();
|
|
mermaidViewerRef.current = null;
|
|
}
|
|
const replacement = document.createElement('div');
|
|
replacement.setAttribute('data-md-block', '');
|
|
replacement.style.display = 'contents';
|
|
replacement.innerHTML = block.html;
|
|
decorateMarkdown(replacement, ctx);
|
|
replacement.setAttribute('data-md-id', block.id);
|
|
replacement.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
|
el.replaceWith(replacement);
|
|
if (hasMermaidBlock || shouldRefreshMermaidViewers(replacement)) refreshMermaidViewers();
|
|
}
|
|
if (!mermaidViewerRef.current && shouldRefreshMermaidViewers(el)) {
|
|
refreshMermaidViewers();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const temp = document.createElement('div');
|
|
temp.innerHTML = block.html;
|
|
decorateMarkdown(temp, ctx);
|
|
if (isNewBlock && streaming && index > 0) {
|
|
// A freshly committed block enters with a short reveal. The class
|
|
// goes on the block's children — the wrapper is display:contents
|
|
// and cannot animate — and the transform never changes layout, so
|
|
// row measurement stays exact. Skipped for the first block so a
|
|
// full initial render does not shimmer. Several blocks committed
|
|
// in one tick cascade with a small stagger instead of popping in
|
|
// together.
|
|
const delayMs = Math.min(enteredThisPass, 4) * 55;
|
|
enteredThisPass += 1;
|
|
for (const child of Array.from(temp.children)) {
|
|
child.classList.add('oc-md-block-enter');
|
|
if (delayMs > 0 && child instanceof HTMLElement) {
|
|
child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`);
|
|
}
|
|
}
|
|
}
|
|
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
|
|
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
|
|
morphdom(el, temp, {
|
|
childrenOnly: true,
|
|
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
|
|
});
|
|
el.setAttribute('data-md-id', block.id);
|
|
el.setAttribute(MARKDOWN_DECORATION_ID_ATTR, decorationId);
|
|
if (hadMermaidBlock || tempHasMermaidBlock || shouldRefreshMermaidViewers(el)) {
|
|
refreshMermaidViewers();
|
|
}
|
|
});
|
|
|
|
const hadMermaidBeforeTrailingCleanup = shouldRefreshMermaidViewers(target);
|
|
let removedMermaidBlock = false;
|
|
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
|
|
const removed = existing[i];
|
|
if (removed && shouldRefreshMermaidViewers(removed)) {
|
|
removedMermaidBlock = true;
|
|
}
|
|
removed?.remove();
|
|
}
|
|
if (removedMermaidBlock || (existing.length > blocks.length && hadMermaidBeforeTrailingCleanup)) {
|
|
refreshMermaidViewers();
|
|
}
|
|
mountedDomRef.current = domCacheKey
|
|
? { key: domCacheKey, copiedLabel: ctx.labels.copied }
|
|
: null;
|
|
releaseRevealHold();
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [containerRef, ctx, domCacheKey, imageMode, refreshMermaidViewers, releaseRevealHold, streaming, text]);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
return attachMarkdownInteractions(container, ctx);
|
|
}, [containerRef, ctx]);
|
|
|
|
// Apply syntax CSS variables imperatively so they survive morphdom updates.
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
|
if (!target) return;
|
|
for (const [key, value] of Object.entries(syntaxVars)) {
|
|
target.style.setProperty(key, value);
|
|
}
|
|
}, [containerRef, syntaxVars]);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
|
if (!target) return;
|
|
if (ctx.deferCodeLineNumberSync) return;
|
|
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
|
|
}, [containerRef, ctx.codeBlockLineWrap, ctx.deferCodeLineNumberSync, ctx.labels]);
|
|
|
|
};
|
|
|
|
const markdownContentClassName = (variant: MarkdownVariant): string =>
|
|
variant === 'tool'
|
|
? 'markdown-content markdown-tool'
|
|
: variant === 'reasoning'
|
|
? 'markdown-content markdown-reasoning'
|
|
: 'markdown-content leading-relaxed';
|
|
|
|
const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
|
content,
|
|
part,
|
|
messageId,
|
|
isAnimated = true,
|
|
skipFadeIn = false,
|
|
className,
|
|
isStreaming = false,
|
|
disableStreamAnimation = false,
|
|
variant = 'assistant',
|
|
onShowPopup,
|
|
enableFileReferences = true,
|
|
}) => {
|
|
streamPerfCount('ui.markdown_renderer.render');
|
|
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
|
|
streamPerfObserve('ui.markdown_renderer.content_len', content.length);
|
|
const currentTheme = useCurrentMermaidTheme();
|
|
const { editor, runtime } = useRuntimeAPIs();
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
|
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
|
|
|
const handlePreviewLoopback = React.useCallback((url: string) => {
|
|
if (!effectiveDirectory) return;
|
|
openContextPreview(effectiveDirectory, url);
|
|
}, [effectiveDirectory, openContextPreview]);
|
|
|
|
const live = isStreaming && !disableStreamAnimation;
|
|
|
|
useMermaidInlineInteractions({
|
|
containerRef,
|
|
onShowPopup,
|
|
enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED,
|
|
enablePanZoom: DEFAULT_MERMAID_CONTROLS.showPanZoomControls,
|
|
});
|
|
useFileReferenceInteractions({
|
|
containerRef,
|
|
effectiveDirectory,
|
|
editor,
|
|
preferRuntimeEditor: runtime.isVSCode,
|
|
enabled: enableFileReferences && !isStreaming,
|
|
});
|
|
useLinkInteractions({ containerRef });
|
|
|
|
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
|
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
|
const { locale } = useI18n();
|
|
const imageMode: MarkdownImageMode = variant === 'assistant' ? 'label' : 'inline';
|
|
const settledPart = part
|
|
&& (part.type === 'text' || part.type === 'reasoning')
|
|
&& part.time?.end !== undefined
|
|
? part
|
|
: null;
|
|
const runtimeKey = getRuntimeKey();
|
|
// Memoized on scalar identities, not the part object: sync-store reducers
|
|
// recreate part objects on unrelated updates, and an object-identity dep
|
|
// re-ran the async render pipeline for identical content.
|
|
const settledSessionID = settledPart?.sessionID;
|
|
const settledMessageID = settledPart?.messageID;
|
|
const settledPartID = settledPart?.id;
|
|
const domCacheKey = React.useMemo<DetachedMarkdownDomKey | null>(() => {
|
|
// Streaming, unfinished, oversized, and identity-less Markdown continues
|
|
// through the normal rendering pipeline and never retains detached DOM.
|
|
if (isStreaming || !settledSessionID || !settledMessageID || !settledPartID || content.length === 0 || content.length > MARKDOWN_DOM_CACHE_MAX_SOURCE_CHARS) return null;
|
|
// content.length is a cheap fingerprint: an edited or reverted part that
|
|
// re-materializes under the same id must not restore the old DOM.
|
|
return {
|
|
scope: `${runtimeKey}\0${settledSessionID}`,
|
|
id: `${settledMessageID}\0${settledPartID}\0${imageMode}\0${content.length}`,
|
|
locale,
|
|
directory: effectiveDirectory,
|
|
};
|
|
}, [content.length, effectiveDirectory, imageMode, isStreaming, locale, runtimeKey, settledSessionID, settledMessageID, settledPartID]);
|
|
// Identity for the fade-in wrapper: a new part/message restarts the animation.
|
|
const fadeKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
|
|
|
useMorphdomMarkdown({
|
|
containerRef,
|
|
text: content,
|
|
streaming: live,
|
|
imageMode,
|
|
syntaxVars,
|
|
ctx,
|
|
domCacheKey,
|
|
});
|
|
|
|
const markdownContent = (
|
|
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
|
|
<div className={markdownContentClassName(variant)} data-markdown-content />
|
|
</div>
|
|
);
|
|
|
|
if (isAnimated) {
|
|
return (
|
|
<FadeInOnReveal key={fadeKey} skipAnimation={skipFadeIn}>
|
|
{markdownContent}
|
|
</FadeInOnReveal>
|
|
);
|
|
}
|
|
|
|
return markdownContent;
|
|
};
|
|
|
|
export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) => {
|
|
return prev.content === next.content
|
|
&& prev.isStreaming === next.isStreaming
|
|
&& prev.disableStreamAnimation === next.disableStreamAnimation
|
|
&& prev.variant === next.variant
|
|
&& prev.isAnimated === next.isAnimated
|
|
&& prev.skipFadeIn === next.skipFadeIn
|
|
&& prev.className === next.className
|
|
&& prev.messageId === next.messageId
|
|
&& prev.onShowPopup === next.onShowPopup
|
|
&& prev.enableFileReferences === next.enableFileReferences
|
|
&& prev.part?.id === next.part?.id;
|
|
});
|
|
|
|
const SimpleMarkdownRendererImpl: React.FC<{
|
|
content: string;
|
|
className?: string;
|
|
variant?: MarkdownVariant;
|
|
// App links remain confirmed even where ordinary HTTP link handling is off.
|
|
disableLinkSafety?: boolean;
|
|
stripFrontmatter?: boolean;
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
mermaidControls?: MermaidControlOptions;
|
|
allowMermaidWheelEvents?: boolean;
|
|
enableFileReferences?: boolean;
|
|
}> = ({
|
|
content,
|
|
className,
|
|
variant = 'assistant',
|
|
disableLinkSafety,
|
|
stripFrontmatter = false,
|
|
onShowPopup,
|
|
mermaidControls = DEFAULT_MERMAID_CONTROLS,
|
|
allowMermaidWheelEvents = false,
|
|
enableFileReferences = true,
|
|
}) => {
|
|
const { editor, runtime } = useRuntimeAPIs();
|
|
const currentTheme = useCurrentMermaidTheme();
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
|
|
|
const renderedContent = React.useMemo(
|
|
() => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
|
|
[content, stripFrontmatter],
|
|
);
|
|
|
|
useMermaidInlineInteractions({
|
|
containerRef,
|
|
onShowPopup,
|
|
enableFullscreen: DEFAULT_MERMAID_FULLSCREEN_ENABLED,
|
|
enablePanZoom: mermaidControls.showPanZoomControls,
|
|
allowMermaidWheelEvents,
|
|
});
|
|
useFileReferenceInteractions({
|
|
containerRef,
|
|
effectiveDirectory,
|
|
editor,
|
|
preferRuntimeEditor: runtime.isVSCode,
|
|
enabled: enableFileReferences,
|
|
});
|
|
useLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
|
|
|
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
|
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
|
|
|
|
useMorphdomMarkdown({
|
|
containerRef,
|
|
text: renderedContent,
|
|
streaming: false,
|
|
syntaxVars,
|
|
ctx,
|
|
});
|
|
|
|
return (
|
|
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
|
|
<div className={markdownContentClassName(variant)} data-markdown-content />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const SimpleMarkdownRenderer = React.memo(SimpleMarkdownRendererImpl, (prev, next) => {
|
|
const prevMermaidControls = prev.mermaidControls ?? DEFAULT_MERMAID_CONTROLS;
|
|
const nextMermaidControls = next.mermaidControls ?? DEFAULT_MERMAID_CONTROLS;
|
|
|
|
return prev.content === next.content
|
|
&& prev.variant === next.variant
|
|
&& prev.className === next.className
|
|
&& prev.disableLinkSafety === next.disableLinkSafety
|
|
&& prev.stripFrontmatter === next.stripFrontmatter
|
|
&& prev.onShowPopup === next.onShowPopup
|
|
&& prevMermaidControls.download === nextMermaidControls.download
|
|
&& prevMermaidControls.copy === nextMermaidControls.copy
|
|
&& prevMermaidControls.showPanZoomControls === nextMermaidControls.showPanZoomControls
|
|
&& prev.allowMermaidWheelEvents === next.allowMermaidWheelEvents
|
|
&& prev.enableFileReferences === next.enableFileReferences;
|
|
});
|