feat: add markdown image gallery previews

This commit is contained in:
ChangeHow
2026-08-13 16:19:06 +08:00
parent 8a6eca5597
commit f790b58d83
17 changed files with 818 additions and 155 deletions
@@ -19,7 +19,8 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/l
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 { getMarkdownImageFilename, renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
import { resolveMarkdownImageSource } from './markdown/markdownImageAssets';
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
import {
@@ -107,6 +108,59 @@ const useExternalLinkInteractions = ({
}, [containerRef, enabled]);
};
const useMarkdownImageLinkInteractions = ({
containerRef,
directory,
enabled,
onShowPopup,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
directory: string;
enabled: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
}) => {
React.useEffect(() => {
const container = containerRef.current;
if (!enabled || !container || !onShowPopup) return;
const controller = new AbortController();
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0) return;
const target = event.target;
if (!(target instanceof Element)) return;
const link = target.closest<HTMLAnchorElement>('[data-openchamber-markdown-image-link="true"]');
if (!link || !container.contains(link)) return;
const source = link.getAttribute('data-openchamber-markdown-image-source') ?? '';
const filename = link.getAttribute('data-openchamber-markdown-image-filename')
|| getMarkdownImageFilename(source, '');
if (!source || !filename) return;
event.preventDefault();
event.stopPropagation();
void resolveMarkdownImageSource(source, directory, controller.signal)
.then((url) => {
if (controller.signal.aborted || !link.isConnected) return;
onShowPopup({
open: true,
title: filename,
content: '',
metadata: { tool: 'markdown-image-preview', filename },
image: { url, filename },
});
})
.catch(() => undefined);
};
container.addEventListener('click', handleClick);
return () => {
controller.abort();
container.removeEventListener('click', handleClick);
};
}, [containerRef, directory, enabled, onShowPopup]);
};
const DEFAULT_MERMAID_CONTROLS: MermaidControlOptions = {
download: true,
copy: true,
@@ -140,6 +194,7 @@ interface MarkdownRendererProps {
variant?: MarkdownVariant;
onShowPopup?: (content: ToolPopupContent) => void;
enableFileReferences?: boolean;
enableLocalImages?: boolean;
}
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
@@ -497,6 +552,10 @@ const useFileReferenceInteractions = ({
let linkedCount = 0;
for (const candidate of Array.from(candidates)) {
if (candidate.matches('[data-openchamber-markdown-image-link="true"]')) {
clearFileLinkAttributes(candidate);
continue;
}
const rawCandidate = extractPathCandidateFromElement(candidate);
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
clearFileLinkAttributes(candidate);
@@ -831,6 +890,7 @@ const useMorphdomMarkdown = ({
text,
streaming,
cacheKey,
deferImages = false,
syntaxVars,
ctx,
}: {
@@ -838,6 +898,7 @@ const useMorphdomMarkdown = ({
text: string;
streaming: boolean;
cacheKey: string;
deferImages?: boolean;
syntaxVars: Record<string, string>;
ctx: DecorateContext;
}) => {
@@ -876,7 +937,7 @@ const useMorphdomMarkdown = ({
// `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);
block.innerHTML = renderMarkdownSync(text, deferImages);
// 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
@@ -888,7 +949,7 @@ const useMorphdomMarkdown = ({
refreshMermaidViewers();
}
}
}, [containerRef, text, ctx, refreshMermaidViewers]);
}, [containerRef, text, deferImages, ctx, refreshMermaidViewers]);
React.useEffect(() => () => {
mermaidViewerRef.current?.cleanup();
@@ -901,7 +962,7 @@ const useMorphdomMarkdown = ({
const target = container.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
let active = true;
void renderMarkdownBlocks(text, streaming, cacheKey).then((blocks) => {
void renderMarkdownBlocks(text, streaming, cacheKey, deferImages).then((blocks) => {
if (!active) return;
const existing = Array.from(target.children) as HTMLElement[];
@@ -952,7 +1013,7 @@ const useMorphdomMarkdown = ({
return () => {
active = false;
};
}, [containerRef, text, streaming, cacheKey, ctx, refreshMermaidViewers]);
}, [containerRef, text, streaming, cacheKey, deferImages, ctx, refreshMermaidViewers]);
React.useEffect(() => {
const container = containerRef.current;
@@ -999,6 +1060,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
variant = 'assistant',
onShowPopup,
enableFileReferences = true,
enableLocalImages = false,
}) => {
streamPerfCount('ui.markdown_renderer.render');
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
@@ -1030,15 +1092,33 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
enabled: enableFileReferences && !isStreaming,
});
useExternalLinkInteractions({ containerRef });
useMarkdownImageLinkInteractions({
containerRef,
directory: effectiveDirectory,
enabled: enableLocalImages && variant === 'assistant' && !isStreaming,
onShowPopup,
});
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
useMorphdomMarkdown({
containerRef,
text: content,
streaming: live,
cacheKey,
deferImages: enableLocalImages && variant === 'assistant' && !isStreaming,
syntaxVars,
ctx,
});
const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
<div
className={cn('break-words w-full min-w-0', className)}
ref={containerRef}
data-openchamber-finalized-assistant-images={enableLocalImages && variant === 'assistant' && !isStreaming ? 'true' : undefined}
>
<div className={markdownContentClassName(variant)} data-markdown-content />
</div>
);
@@ -1065,6 +1145,7 @@ export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) =>
&& prev.messageId === next.messageId
&& prev.onShowPopup === next.onShowPopup
&& prev.enableFileReferences === next.enableFileReferences
&& prev.enableLocalImages === next.enableLocalImages
&& prev.part?.id === next.part?.id;
});