Adds line-number gutters for markdown code blocks Keeps gutter heights in sync when wrapping or resizing changes Applies wrap styles directly to pre and code for better overflow handling
1195 lines
39 KiB
TypeScript
1195 lines
39 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 { runtimeFetch } from '@/lib/runtime-fetch';
|
|
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
|
|
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
|
import type { Theme } from '@/types/theme';
|
|
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 { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
|
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
|
|
import {
|
|
attachMarkdownInteractions,
|
|
applyMarkdownCodeBlockWrapState,
|
|
decorateMarkdown,
|
|
scheduleMarkdownCodeLineNumberSync,
|
|
syncMarkdownCodeLineNumbers,
|
|
type DecorateContext,
|
|
type DecorateLabels,
|
|
type MermaidRender,
|
|
} from './markdown/decorate';
|
|
import {
|
|
BLOCK_PATH_TOKEN_RE,
|
|
isAbsoluteReferencePath,
|
|
normalizeReferencePath,
|
|
parseFileReference,
|
|
type ParsedFileReference,
|
|
} from './fileReferenceParser';
|
|
|
|
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 useExternalLinkInteractions = ({
|
|
containerRef,
|
|
enabled,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
enabled?: boolean;
|
|
}) => {
|
|
React.useEffect(() => {
|
|
if (enabled === false) {
|
|
return;
|
|
}
|
|
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const handleClick = (event: MouseEvent) => {
|
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
|
|
return;
|
|
}
|
|
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const anchor = target.closest('a[href]');
|
|
if (!(anchor instanceof HTMLAnchorElement)) {
|
|
return;
|
|
}
|
|
|
|
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
|
|
return;
|
|
}
|
|
|
|
const href = anchor.getAttribute('href') ?? '';
|
|
if (!isExternalHttpUrl(href)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
void openExternalUrl(href);
|
|
};
|
|
|
|
container.addEventListener('click', handleClick);
|
|
return () => {
|
|
container.removeEventListener('click', handleClick);
|
|
};
|
|
}, [containerRef, enabled]);
|
|
};
|
|
|
|
type MermaidControlOptions = {
|
|
download: boolean;
|
|
copy: boolean;
|
|
fullscreen: boolean;
|
|
panZoom: boolean;
|
|
};
|
|
|
|
const extractMermaidBlocks = (markdown: string): string[] => {
|
|
if (!markdown.includes('mermaid')) return [];
|
|
const blocks: string[] = [];
|
|
const regex = /(?:^|\r?\n)(`{3,}|~{3,})mermaid[^\n\r]*\r?\n([\s\S]*?)\r?\n\1(?=\r?\n|$)/gi;
|
|
let match: RegExpExecArray | null = regex.exec(markdown);
|
|
|
|
while (match) {
|
|
const block = (match[2] ?? '').replace(/\s+$/, '');
|
|
blocks.push(block);
|
|
match = regex.exec(markdown);
|
|
}
|
|
|
|
return blocks;
|
|
};
|
|
|
|
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 MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
|
|
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_STAT_CONCURRENCY = 4;
|
|
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
|
|
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
|
|
const FILE_REFERENCE_LINK_LIMIT = 80;
|
|
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
|
|
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
|
|
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
|
|
let activeFileReferenceStatCount = 0;
|
|
const pendingFileReferenceStats: Array<() => void> = [];
|
|
|
|
const getFileReferenceStatCacheMax = (): number => (
|
|
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
|
|
);
|
|
|
|
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 findTextPosition = (textNodes: Text[], targetOffset: number): { node: Text; offset: number } | null => {
|
|
let currentOffset = 0;
|
|
|
|
for (const node of textNodes) {
|
|
const nextOffset = currentOffset + node.data.length;
|
|
if (targetOffset <= nextOffset) {
|
|
return { node, offset: Math.max(0, targetOffset - currentOffset) };
|
|
}
|
|
currentOffset = nextOffset;
|
|
}
|
|
|
|
const lastNode = textNodes.at(-1);
|
|
return lastNode ? { node: lastNode, offset: lastNode.data.length } : null;
|
|
};
|
|
|
|
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();
|
|
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) {
|
|
textNodes.push(currentNode as Text);
|
|
currentNode = walker.nextNode();
|
|
}
|
|
|
|
const fullText = codeBlock.textContent ?? '';
|
|
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);
|
|
const endPosition = findTextPosition(textNodes, end);
|
|
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 fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
|
|
const normalizedPath = normalizePath(resolvedPath);
|
|
if (!normalizedPath) {
|
|
return Promise.resolve(false);
|
|
}
|
|
|
|
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
|
|
if (cached) {
|
|
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
|
|
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
|
|
return cached;
|
|
}
|
|
|
|
const request = new Promise<boolean>((resolve) => {
|
|
const run = () => {
|
|
activeFileReferenceStatCount += 1;
|
|
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
|
|
method: 'GET',
|
|
cache: 'no-store',
|
|
})
|
|
.then(async (response) => {
|
|
if (!response.ok) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
|
|
resolve(payload?.exists !== false);
|
|
})
|
|
.catch(() => resolve(false))
|
|
.finally(() => {
|
|
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
|
|
pendingFileReferenceStats.shift()?.();
|
|
});
|
|
};
|
|
|
|
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
|
|
run();
|
|
return;
|
|
}
|
|
|
|
pendingFileReferenceStats.push(run);
|
|
});
|
|
|
|
const maxCacheEntries = getFileReferenceStatCacheMax();
|
|
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
|
|
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
|
|
if (typeof oldest !== 'string') {
|
|
break;
|
|
}
|
|
FILE_REFERENCE_STAT_CACHE.delete(oldest);
|
|
}
|
|
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
|
|
return request;
|
|
};
|
|
|
|
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;
|
|
}
|
|
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 = () => {
|
|
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);
|
|
|
|
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);
|
|
|
|
const observer = new MutationObserver(() => {
|
|
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,
|
|
mermaidBlocks,
|
|
onShowPopup,
|
|
allowWheelZoom,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
mermaidBlocks: string[];
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
allowWheelZoom?: boolean;
|
|
}) => {
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const handleMermaidClick = (event: MouseEvent) => {
|
|
if (!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;
|
|
}
|
|
|
|
const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR));
|
|
const blockIndex = renderedBlocks.indexOf(block);
|
|
if (blockIndex < 0) {
|
|
return;
|
|
}
|
|
|
|
const source = mermaidBlocks[blockIndex];
|
|
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 (allowWheelZoom) {
|
|
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);
|
|
};
|
|
}, [allowWheelZoom, containerRef, mermaidBlocks, onShowPopup]);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rendering core: marked -> math -> shiki -> sanitize -> decorate -> morphdom
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Single tuning knob: the streaming reveal cadence. Lower = smoother but more
|
|
// CPU (more re-parse steps/sec); higher = cheaper but chunkier. Step sizes are
|
|
// auto-scaled from this so reveal throughput (chars/sec) stays constant no
|
|
// matter the cadence — text always keeps up with the incoming stream.
|
|
const TEXT_PACE_MS = 64;
|
|
const PACE_BASELINE_MS = 24;
|
|
const PACE_RATIO = TEXT_PACE_MS / PACE_BASELINE_MS;
|
|
const TEXT_SNAP = /[\s.,!?;:)\]]/;
|
|
|
|
const paceStep = (remaining: number): number => {
|
|
const base = remaining <= 12 ? 2 : remaining <= 48 ? 4 : remaining <= 96 ? 8 : Math.min(24, Math.ceil(remaining / 8));
|
|
return Math.max(1, Math.round(base * PACE_RATIO));
|
|
};
|
|
|
|
const nextRevealIndex = (text: string, start: number): number => {
|
|
const end = Math.min(text.length, start + paceStep(text.length - start));
|
|
for (let i = end; i < Math.min(text.length, end + 8); i += 1) {
|
|
if (TEXT_SNAP.test(text[i] ?? '')) return i + 1;
|
|
}
|
|
return end;
|
|
};
|
|
|
|
// Granular streaming reveal. Cheap because each step only re-runs the
|
|
// marked->morphdom pipeline (patching changed DOM nodes), with no React tree
|
|
// reconciliation of the markdown body.
|
|
const usePacedText = (content: string, streaming: boolean): string => {
|
|
const [shown, setShown] = React.useState<number>(() => (streaming ? 0 : content.length));
|
|
const shownRef = React.useRef(shown);
|
|
shownRef.current = shown;
|
|
|
|
React.useEffect(() => {
|
|
if (!streaming || typeof window === 'undefined') {
|
|
setShown(content.length);
|
|
return;
|
|
}
|
|
if (shownRef.current > content.length) {
|
|
setShown(content.length);
|
|
}
|
|
|
|
let timer: number | null = null;
|
|
const tick = () => {
|
|
const current = Math.min(shownRef.current, content.length);
|
|
if (current >= content.length) {
|
|
timer = null;
|
|
return;
|
|
}
|
|
setShown(nextRevealIndex(content, current));
|
|
timer = window.setTimeout(tick, TEXT_PACE_MS);
|
|
};
|
|
|
|
if (shownRef.current < content.length) {
|
|
timer = window.setTimeout(tick, TEXT_PACE_MS);
|
|
}
|
|
|
|
return () => {
|
|
if (timer !== null) window.clearTimeout(timer);
|
|
};
|
|
}, [content, streaming]);
|
|
|
|
if (!streaming) return content;
|
|
return content.slice(0, Math.min(shown, content.length));
|
|
};
|
|
|
|
// 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 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,
|
|
onPreviewLoopback?: (url: string) => void,
|
|
): DecorateContext => {
|
|
const { t } = useI18n();
|
|
const labels: DecorateLabels = React.useMemo(() => ({
|
|
copy: 'Copy code',
|
|
copied: 'Copied',
|
|
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'),
|
|
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, codeBlockLineWrap, onToggleCodeBlockLineWrap: toggleCodeBlockLineWrap, renderMermaid, onPreviewLoopback };
|
|
}, [currentTheme, labels, codeBlockLineWrap, toggleCodeBlockLineWrap, onPreviewLoopback]);
|
|
};
|
|
|
|
// Runs the async render pipeline into the container and keeps a stable
|
|
// delegated interaction listener attached.
|
|
const useMorphdomMarkdown = ({
|
|
containerRef,
|
|
text,
|
|
streaming,
|
|
cacheKey,
|
|
syntaxVars,
|
|
ctx,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
text: string;
|
|
streaming: boolean;
|
|
cacheKey: string;
|
|
syntaxVars: Record<string, string>;
|
|
ctx: DecorateContext;
|
|
}) => {
|
|
React.useEffect(() => {
|
|
ensureMarkdownShikiTheme();
|
|
}, []);
|
|
|
|
// 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;
|
|
if (text && target.childNodes.length === 0) {
|
|
const block = document.createElement('div');
|
|
block.setAttribute('data-md-block', '');
|
|
// `display:contents` keeps margin-collapsing/spacing identical to a flat
|
|
// HTML body — the wrapper exists only for per-block reconciliation.
|
|
block.style.display = 'contents';
|
|
block.innerHTML = renderMarkdownSync(text);
|
|
// Decorate synchronously too: wrap code blocks in their framed card,
|
|
// mark inline code, build table controls, etc. The async pass re-decorates
|
|
// its own DOM before morphing, so without this the first paint shows bare
|
|
// <pre>/tables that "snap" into their decorated form a tick later. Matching
|
|
// the structure here keeps the async morph to syntax colors only.
|
|
decorateMarkdown(block, ctx);
|
|
target.appendChild(block);
|
|
}
|
|
}, [containerRef, text, ctx]);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
|
let active = true;
|
|
|
|
void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => {
|
|
if (!active) 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.
|
|
blocks.forEach((block, index) => {
|
|
let el = existing[index];
|
|
if (!el) {
|
|
el = document.createElement('div');
|
|
el.setAttribute('data-md-block', '');
|
|
el.style.display = 'contents';
|
|
target.appendChild(el);
|
|
}
|
|
if (el.getAttribute('data-md-id') === block.id) return;
|
|
|
|
const temp = document.createElement('div');
|
|
temp.innerHTML = block.html;
|
|
decorateMarkdown(temp, ctx);
|
|
morphdom(el, temp, {
|
|
childrenOnly: true,
|
|
onBeforeElUpdated: (fromEl, toEl) => !fromEl.isEqualNode(toEl),
|
|
});
|
|
el.setAttribute('data-md-id', block.id);
|
|
});
|
|
|
|
// Remove any trailing block elements no longer present.
|
|
for (let i = existing.length - 1; i >= blocks.length; i -= 1) {
|
|
existing[i]?.remove();
|
|
}
|
|
|
|
scheduleMarkdownCodeLineNumberSync(target);
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [containerRef, text, streaming, cacheKey, ctx]);
|
|
|
|
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;
|
|
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
|
|
}, [containerRef, ctx.codeBlockLineWrap, ctx.labels]);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
|
if (!target || typeof ResizeObserver === 'undefined') return;
|
|
let frame: number | null = null;
|
|
const observer = new ResizeObserver(() => {
|
|
if (frame !== null) window.cancelAnimationFrame(frame);
|
|
frame = window.requestAnimationFrame(() => {
|
|
frame = null;
|
|
syncMarkdownCodeLineNumbers(target);
|
|
});
|
|
});
|
|
observer.observe(target);
|
|
return () => {
|
|
observer.disconnect();
|
|
if (frame !== null) window.cancelAnimationFrame(frame);
|
|
};
|
|
}, [containerRef]);
|
|
};
|
|
|
|
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,
|
|
}) => {
|
|
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;
|
|
const pacedText = usePacedText(content, live);
|
|
|
|
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
|
|
useMermaidInlineInteractions({ containerRef, mermaidBlocks, onShowPopup });
|
|
useFileReferenceInteractions({
|
|
containerRef,
|
|
effectiveDirectory,
|
|
editor,
|
|
preferRuntimeEditor: runtime.isVSCode,
|
|
enabled: enableFileReferences && !isStreaming,
|
|
});
|
|
useExternalLinkInteractions({ containerRef });
|
|
|
|
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
|
const ctx = useDecorateContext(currentTheme, effectiveDirectory ? handlePreviewLoopback : undefined);
|
|
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
|
|
|
useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx });
|
|
|
|
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={cacheKey} 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;
|
|
disableLinkSafety?: boolean;
|
|
stripFrontmatter?: boolean;
|
|
onShowPopup?: (content: ToolPopupContent) => void;
|
|
mermaidControls?: MermaidControlOptions;
|
|
allowMermaidWheelZoom?: boolean;
|
|
enableFileReferences?: boolean;
|
|
}> = ({
|
|
content,
|
|
className,
|
|
variant = 'assistant',
|
|
disableLinkSafety,
|
|
stripFrontmatter = false,
|
|
onShowPopup,
|
|
allowMermaidWheelZoom = 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],
|
|
);
|
|
|
|
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
|
|
useMermaidInlineInteractions({
|
|
containerRef,
|
|
mermaidBlocks,
|
|
onShowPopup,
|
|
allowWheelZoom: allowMermaidWheelZoom,
|
|
});
|
|
useFileReferenceInteractions({
|
|
containerRef,
|
|
effectiveDirectory,
|
|
editor,
|
|
preferRuntimeEditor: runtime.isVSCode,
|
|
enabled: enableFileReferences,
|
|
});
|
|
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
|
|
|
|
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
|
|
const ctx = useDecorateContext(currentTheme);
|
|
|
|
useMorphdomMarkdown({
|
|
containerRef,
|
|
text: renderedContent,
|
|
streaming: false,
|
|
cacheKey: `simple:${variant}`,
|
|
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) => {
|
|
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
|
|
&& prev.allowMermaidWheelZoom === next.allowMermaidWheelZoom
|
|
&& prev.enableFileReferences === next.enableFileReferences;
|
|
});
|