mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(markdown): size inline images to the line and collapse failed loads
This commit is contained in:
@@ -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 = " **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(
|
||||
" and ",
|
||||
);
|
||||
expect(
|
||||
prepared.render(
|
||||
new Map([["broken.png", "file:///broken.png"]]),
|
||||
new Set(["https://example.com/broken.png", "broken.png"]),
|
||||
),
|
||||
).toBe("Remote and Local");
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user