diff --git a/packages/app/src/components/markdown/images.ts b/packages/app/src/components/markdown/images.ts index 0df11589e7..37559fa3e9 100644 --- a/packages/app/src/components/markdown/images.ts +++ b/packages/app/src/components/markdown/images.ts @@ -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 { + return new Promise((resolve) => { + Image.getSize( + uri, + (width, height) => resolve(width > 0 && height > 0), + () => resolve(false), + ); + }); +} diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index e673a5881c..c2d003a067 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -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"); +}); diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index cf81511860..d31ad3fa51 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -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, + 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 = new Map()) { + render( + urls: ReadonlyMap = new Map(), + unavailableImages: ReadonlySet = 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, diff --git a/packages/app/src/components/markdown/styles.ts b/packages/app/src/components/markdown/styles.ts index 582809340c..5a511cf8d0 100644 --- a/packages/app/src/components/markdown/styles.ts +++ b/packages/app/src/components/markdown/styles.ts @@ -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 }, },