diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 99c2ebbc8f40f..a97cc1357bb33 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1608,7 +1608,12 @@ const CHAT_MARKDOWN_COMPONENTS = { fenceTitle={fenceTitle} theme={resolvedTheme} isStreaming={isStreaming && !isClosedCodeFence(node, text, codeBlock.code)} - onExpand={(src) => expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })} + onExpand={(src) => + expandMedia({ + images: [{ src, name: "Mermaid diagram", mimeType: "image/svg+xml" }], + index: 0, + }) + } > {highlightedCode} diff --git a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx index 1ea8d4c558f35..0dc5b367b4e34 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.browser.test.tsx @@ -16,7 +16,46 @@ const snapShotSource: SnapShotSource = { windowTitle: "Downloads", }; +const D2_SVG = + ''; + describe("ExpandedImageDialog", () => { + it.each(["/work/pipeline.svg", "/work/diagram#v2.svg", "/work/diagram?v2.svg"])( + "fits a markdown SVG without intrinsic size to the viewer: %s", + async (path) => { + const src = URL.createObjectURL(new Blob([D2_SVG], { type: "image/svg+xml" })); + using _src = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + await using screen = await render( + , + // The dialog renders no links and reads no route. + { router: false }, + ); + + const viewer = screen.getByRole("region", { name: "Pipeline, zoomable image" }); + const availableWidth = window.innerWidth * 0.92 - (window.innerWidth >= 640 ? 96 : 0); + await vi.waitFor(() => + expect(viewer.element().getBoundingClientRect().width).toBeCloseTo(availableWidth, 0), + ); + }, + ); + it("Escape closes a popup opened inside the dialog before the dialog", async () => { const onClose = vi.fn(); await using screen = await render( diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index aadbcab79b129..72fb4db069216 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -25,6 +25,8 @@ import { Atom } from "effect/reactivity"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { expandedImageKey } from "./ExpandedImagePreview"; import { registerThreadOverlay } from "./threadOverlayRetention"; +import { mediaReferenceFileName } from "@t3tools/client-runtime/media-reference"; +import { mediaMimeType, mediaMimeTypeFromExtension } from "@t3tools/shared/mediaPreview"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; @@ -42,6 +44,16 @@ function ExpandedMediaFailure({ children }: { children: ReactNode }) { ); } +function itemMimeType(item: ExpandedImageItem): string | undefined { + if (item.mimeType) return item.mimeType; + const reference = item.actionsSource?.reference; + const fileName = reference && mediaReferenceFileName(reference); + if (fileName) { + return mediaMimeTypeFromExtension(fileName.slice(fileName.lastIndexOf("."))) ?? undefined; + } + return mediaMimeType(item.src ?? "") ?? undefined; +} + function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) { const asset = item.actionsSource?.asset; const assetUrl = useAssetUrlState(asset?.environmentId ?? null, asset?.resource ?? null); @@ -222,7 +234,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ key={`${index}:${item.src}`} src={item.src} name={item.name} - mimeType={item.mimeType} + mimeType={itemMimeType(item)} onError={() => setFailedImageSrc(item.src)} /> )} diff --git a/apps/web/src/components/chat/ZoomableImage.tsx b/apps/web/src/components/chat/ZoomableImage.tsx index a5ce763d65419..0e8451300dc27 100644 --- a/apps/web/src/components/chat/ZoomableImage.tsx +++ b/apps/web/src/components/chat/ZoomableImage.tsx @@ -71,8 +71,9 @@ export function ZoomableImage({ const suppressClickRef = useRef(false); const [dragging, setDragging] = useState(false); const maxHeight = Math.max(1, Math.min(viewerSize.height * 0.86, viewerSize.height - 160)); + // A vector scales without loss, and an SVG without width and height reports a tiny default size. const fit = Math.min( - 1, + mimeType === "image/svg+xml" ? Infinity : 1, (viewerSize.width * 0.92 - (viewerSize.width >= 640 ? 96 : 0)) / (naturalSize.width || 1), maxHeight / (naturalSize.height || 1), );