diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 61e498f9e..2259d8f4c 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -51,6 +51,7 @@ import { classifyChildren, hasBlockMedia, isImageOnlyParagraph, + reservedImageSize, shallowArrayEqual, } from "./markdownUtils"; import { resolveFileCard } from "./markdownFileCard"; @@ -246,10 +247,12 @@ type MarkdownVariant = "default" | "compact" | "tight"; */ function ImageBlock({ alt, + dim, resolvedSrc, src, }: { alt: string | undefined; + dim?: string; resolvedSrc: string | undefined; src: string | undefined; }) { @@ -299,13 +302,22 @@ function ImageBlock({ }); }; + const reserved = reservedImageSize(dim); + return ( <> {/* biome-ignore lint/a11y/useKeyWithClickEvents: image opens lightbox on click; keyboard equivalent handled by lightbox close button */} {alt} setLightboxOpen(true)} onContextMenuCapture={handleContextMenu} /> @@ -911,7 +923,12 @@ function createMarkdownComponents( } return ( - + ); }, diff --git a/desktop/src/shared/ui/markdownUtils.ts b/desktop/src/shared/ui/markdownUtils.ts index dab951285..886813102 100644 --- a/desktop/src/shared/ui/markdownUtils.ts +++ b/desktop/src/shared/ui/markdownUtils.ts @@ -65,3 +65,46 @@ export function shallowArrayEqual(a?: string[], b?: string[]): boolean { } return true; } + +// Display caps for inline message images — must mirror the `max-h-64 max-w-sm` +// (256×384px) Tailwind classes on the rendered . +export const INLINE_IMAGE_MAX_WIDTH = 384; +export const INLINE_IMAGE_MAX_HEIGHT = 256; + +/** + * Compute the exact rendered box for an inline image from its imeta `dim` + * ("WIDTHxHEIGHT"), scaled to fit the display caps (same fit as `object-contain` + * within `max-w-sm`/`max-h-64`). Used to reserve the row's height via the + * width/height attributes BEFORE the bytes load — without it, image rows paint + * at height 0 then grow when the image arrives, thrashing the virtualized list + * (every late image re-measures and shifts its neighbors). + * + * Returns undefined when `dim` is missing/malformed/non-positive — we can't + * reserve what we don't know, so the natural load is the correct fallback. + */ +export function reservedImageSize( + dim: string | undefined, +): { width: number; height: number } | undefined { + if (!dim) return undefined; + const match = dim.match(/^(\d+)x(\d+)$/i); + if (!match) return undefined; + const width = Number(match[1]); + const height = Number(match[2]); + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + width <= 0 || + height <= 0 + ) { + return undefined; + } + const scale = Math.min( + 1, + INLINE_IMAGE_MAX_WIDTH / width, + INLINE_IMAGE_MAX_HEIGHT / height, + ); + return { + width: Math.round(width * scale), + height: Math.round(height * scale), + }; +} diff --git a/desktop/src/shared/ui/reservedImageSize.dom.test.tsx b/desktop/src/shared/ui/reservedImageSize.dom.test.tsx new file mode 100644 index 000000000..2a021fb55 --- /dev/null +++ b/desktop/src/shared/ui/reservedImageSize.dom.test.tsx @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +// Imported through the DOM lane (esbuild) so we can pull from markdownUtils.ts +// directly even though it imports React — no stale inlined copy. Pure-math +// helper, but it lives in a React module, hence the .dom lane. +import { + INLINE_IMAGE_MAX_HEIGHT, + INLINE_IMAGE_MAX_WIDTH, + reservedImageSize, +} from "./markdownUtils"; + +test("returns undefined when dim is missing or malformed", () => { + assert.equal(reservedImageSize(undefined), undefined); + assert.equal(reservedImageSize(""), undefined); + assert.equal(reservedImageSize("800"), undefined); + assert.equal(reservedImageSize("800x"), undefined); + assert.equal(reservedImageSize("axb"), undefined); + assert.equal(reservedImageSize("0x600"), undefined); + assert.equal(reservedImageSize("800x0"), undefined); +}); + +test("small image within caps is reserved at its natural size", () => { + // 200x100 fits inside 384x256 → no scaling. + assert.deepEqual(reservedImageSize("200x100"), { width: 200, height: 100 }); +}); + +test("wide image scales down to the width cap, preserving aspect ratio", () => { + // 800x400 (2:1). Width cap 384 → scale 0.48 → 384x192. + assert.deepEqual(reservedImageSize("800x400"), { width: 384, height: 192 }); +}); + +test("tall image scales down to the height cap, preserving aspect ratio", () => { + // 400x800 (1:2). Height cap 256 → scale 0.32 → 128x256. + assert.deepEqual(reservedImageSize("400x800"), { width: 128, height: 256 }); +}); + +test("reserved box never exceeds the display caps", () => { + const r = reservedImageSize("4000x3000"); + assert.ok(r); + assert.ok(r.width <= INLINE_IMAGE_MAX_WIDTH); + assert.ok(r.height <= INLINE_IMAGE_MAX_HEIGHT); + // 4:3 → constrained by height (256) → 341x256. + assert.deepEqual(r, { width: 341, height: 256 }); +});