fix(markdown): size inline images to the line and collapse failed loads

This commit is contained in:
Mohamed Boudra
2026-10-15 16:22:37 +02:00
parent 58f2fed621
commit 9e275bf361
4 changed files with 97 additions and 15 deletions
+33 -2
View File
@@ -1,3 +1,5 @@
import { Image } from "react-native";
import { isWeb } from "@/constants/platform";
import { useMemo, useEffect, useId } from "react";
import { useFetchQueries } from "@/data/query";
import { createAssistantImageFileAcquisition } from "@/assistant-image/file-acquisition";
@@ -35,7 +37,7 @@ export interface MarkdownImageContext {
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
const occurrenceKey = useId();
const prepared = useMemo(
() => prepareMarkdownSource(text, context.workspaceRoot),
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }),
[text, context.workspaceRoot],
);
const sources = [...new Set(prepared.images.map((image) => image.source))];
@@ -90,10 +92,39 @@ export function useMarkdownSource(text: string, context: MarkdownImageContext) {
results.forEach((result, index) => {
if (result.data) resolved.set(sources[index]!, result.data.uri);
});
const displayUrls = sources.map(
(source) =>
resolved.get(source) ?? (/^(https?:|data:|blob:)/i.test(source) ? source : undefined),
);
const decoded = useFetchQueries(
displayUrls.map((uri, index) => ({
queryKey: ["markdownImageDecode", occurrenceKey, uri ?? sources[index]],
enabled: uri !== undefined,
immutableWhen: () => true,
dataShape: "value",
gcTime: 0,
queryFn: () => (uri ? canDecodeImage(uri) : Promise.resolve(false)),
})),
);
const unavailableImages = new Set(
sources.filter(
(_source, index) => displayUrls[index] !== undefined && decoded[index]?.data !== true,
),
);
return {
markdown: prepared.render(resolved),
markdown: prepared.render(resolved, unavailableImages),
linkRecognition: prepared.linkRecognition,
linkSource: prepared.linkSource,
imageSources: Object.fromEntries([...resolved].map(([source, url]) => [url, source])),
};
}
function canDecodeImage(uri: string): Promise<boolean> {
return new Promise((resolve) => {
Image.getSize(
uri,
(width, height) => resolve(width > 0 && height > 0),
() => resolve(false),
);
});
}
@@ -265,3 +265,30 @@ it("keeps the renderer root and child identity as workspace images resolve", ()
paseoMarkdownImageSources: JSON.stringify({ "blob:resolved": "/repo/image.png" }),
});
});
it.each([false, true])(
"caps inline images to the paragraph line height (compact: %s)",
(compact) => {
const { markdownStyle } = createMarkdownStyles(darkTheme, compact);
expect(markdownStyle.inlineImage?.size).toBe(markdownStyle.paragraph?.lineHeight);
},
);
it("shows native SVG badges as alt text without reserving an image box", () => {
const source = "![P1](https://example.com/p1.svg?v=9) **Finding**";
const prepared = prepareMarkdownSource(source, undefined, { supportsSvg: false });
expect(prepared.render()).toBe("P1 **Finding**");
expect(prepared.images).toEqual([]);
});
it("collapses images that fail to load or decode to their alt text", () => {
const prepared = prepareMarkdownSource(
"![Remote](https://example.com/broken.png) and ![Local](broken.png)",
);
expect(
prepared.render(
new Map([["broken.png", "file:///broken.png"]]),
new Set(["https://example.com/broken.png", "broken.png"]),
),
).toBe("Remote and Local");
});
+36 -13
View File
@@ -49,7 +49,11 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
}
/** Preserve source spelling; adapt workspace images, spoiler delimiters, and entity text. */
export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
export function prepareMarkdownSource(
source: string,
workspaceRoot?: string,
options: { supportsSvg?: boolean } = {},
) {
source = normalizeMarkdownHtml(source);
const images: MarkdownImage[] = [];
let tree: Root | undefined;
@@ -74,6 +78,29 @@ export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
if ("children" in node) node.children.forEach(definitionsIn);
}
definitionsIn(getTree());
function collectImage(
node: Extract<Nodes, { type: "image" | "imageReference" }>,
start: number,
end: number,
) {
const target = node.type === "image" ? node : definitions.get(node.identifier);
if (
target &&
options.supportsSvg === false &&
/(?:\.svg(?:[?#]|$)|^data:image\/svg\+xml)/i.test(target.url)
) {
edits.push({ start, end, text: node.alt ?? "" });
return;
}
if (target)
images.push({
source: target.url,
alt: node.alt ?? "",
title: target.title ?? null,
start,
end,
});
}
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
@@ -92,15 +119,7 @@ export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
}
}
if (node.type === "image" || node.type === "imageReference") {
const target = node.type === "image" ? node : definitions.get(node.identifier);
if (target)
images.push({
source: target.url,
alt: node.alt ?? "",
title: target.title ?? null,
start,
end,
});
collectImage(node, start, end);
return;
}
if (node.type === "text") {
@@ -119,11 +138,15 @@ export function prepareMarkdownSource(source: string, workspaceRoot?: string) {
return {
images,
...linkMetadata(),
render(urls: ReadonlyMap<string, string> = new Map()) {
render(
urls: ReadonlyMap<string, string> = new Map(),
unavailableImages: ReadonlySet<string> = new Set(),
) {
const replacements = [...edits];
for (const image of images) {
if (/^(https?:|data:|blob:)/i.test(image.source)) continue;
const url = urls.get(image.source);
const unavailable = unavailableImages.has(image.source);
if (!unavailable && /^(https?:|data:|blob:)/i.test(image.source)) continue;
const url = unavailable ? undefined : urls.get(image.source);
replacements.push({
start: image.start,
end: image.end,
@@ -93,6 +93,7 @@ export function createMarkdownStyles(
checkmarkColor: theme.colors.accentForeground,
borderColor: theme.colors.foregroundMuted,
},
inlineImage: { size: paragraph.lineHeight },
image: { height: 320, resizeMode: "contain", borderRadius: 8 },
thematicBreak: { color: theme.colors.border, height: 1, marginTop: 12, marginBottom: 12 },
},