feat: add markdown image gallery previews
This commit is contained in:
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user