From 6df0add6e65b7c0040b5774635f794e4ff802ddd Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 8 Sep 2026 00:52:07 -0300 Subject: [PATCH] fix(web): navigate markdown images as galleries (#10625) --- apps/web/src/components/ChatMarkdown.tsx | 64 +++++++++++-------- .../components/chat/ExpandedImageDialog.tsx | 29 +++++---- .../components/chat/markdownImageGallery.ts | 51 +++++++++++++++ .../pullRequest/PullRequestMarkdown.tsx | 2 +- apps/web/src/components/ui/button.tsx | 1 + 5 files changed, 107 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/chat/markdownImageGallery.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dff486df8767d79834c6d51d10479b2144ccd9f4..02c61d8e820707340a49002a6f9611f62fffb1a2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -88,6 +88,7 @@ import { type ExpandedImagePreview, } from "./chat/ExpandedImagePreview"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; +import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; @@ -1283,10 +1284,7 @@ const MarkdownLinkContext = React.createContext(false); function expandableMarkdownImageProps( onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, - src: string, alt: string, - originalUrl?: string, - actionsSource?: MediaActionSource, ) { if (!onImageExpand) return {}; const previewName = alt.trim() || "image"; @@ -1294,17 +1292,8 @@ function expandableMarkdownImageProps( if (event.currentTarget.closest("a")) return; event.preventDefault(); event.stopPropagation(); - onImageExpand({ - images: [ - { - src, - name: previewName, - ...(originalUrl ? { originalUrl } : {}), - ...(actionsSource ? { actionsSource } : {}), - }, - ], - index: 0, - }); + const item = markdownImageItems.get(event.currentTarget); + if (item) onImageExpand(markdownImageGallery(event.currentTarget, item)); }; return { role: "button" as const, @@ -1398,9 +1387,19 @@ function ChatMarkdownImage(props: { // A failure forgets the decoded image so the next URL loads behind the slot. const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); // Cached images are complete before `onLoad` can fire. - const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => { - if (image?.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); - }, []); + const markLoadedIfComplete = useCallback( + (image: HTMLImageElement | null) => { + if (!image) return; + if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); + markdownImageItems.set(image, { + src, + name: props.alt.trim() || "image", + actionsSource: props.actionsSource, + ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), + }); + }, + [props.actionsSource, props.alt, props.originalUrl, src], + ); const imageEvents = (loadingSrc: string) => ({ onLoad: () => { setLoadedSrc(loadingSrc); @@ -1429,13 +1428,7 @@ function ChatMarkdownImage(props: { props.onImageExpand && "cursor-zoom-in", )} style={props.style} - {...expandableMarkdownImageProps( - props.onImageExpand, - src, - props.alt, - props.originalUrl, - props.actionsSource, - )} + {...expandableMarkdownImageProps(props.onImageExpand, props.alt)} {...imageEvents(src)} /> @@ -2173,6 +2166,7 @@ function useChatMarkdownState({ }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); + const markdownRef = useRef(null); const expandMedia = onImageExpand ?? setLocalMediaPreview; const mediaRequestId = useRef(0); useEffect(() => { @@ -2203,7 +2197,7 @@ function useChatMarkdownState({ ); const preparedConnection = usePreparedConnection(environmentId); const openMarkdownMedia = useCallback( - (source: string, resolvedFilePath?: string) => { + (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => { const requestId = ++mediaRequestId.current; void resolveMarkdownMediaPreview({ source, @@ -2218,7 +2212,14 @@ function useChatMarkdownState({ : undefined, }).then( (preview) => { - if (preview && mediaRequestId.current === requestId) expandMedia(preview); + if (preview && mediaRequestId.current === requestId) { + const selected = preview.images[preview.index]; + expandMedia( + selected && selected.type !== "video" && markdownRef.current + ? markdownImageGallery(clickedImage ?? markdownRef.current, selected) + : preview, + ); + } }, (error: unknown) => { if (mediaRequestId.current !== requestId) return; @@ -2625,6 +2626,7 @@ function useChatMarkdownState({ return { componentState, handleCopy, + markdownRef, markdownUrlTransform, localMediaPreview, setLocalMediaPreview, @@ -2812,7 +2814,13 @@ const CHAT_MARKDOWN_COMPONENTS = { ) { event.preventDefault(); event.stopPropagation(); - openMarkdownMedia(href); + openMarkdownMedia( + href, + undefined, + event.target instanceof HTMLImageElement + ? event.target + : event.currentTarget.querySelector("img"), + ); return; } // A link to a change request in a workspace project opens beside the @@ -3116,6 +3124,7 @@ function ChatMarkdown({ const { componentState, handleCopy, + markdownRef, markdownUrlTransform, localMediaPreview, setLocalMediaPreview, @@ -3133,6 +3142,7 @@ function ChatMarkdown({ // complete source token instead of dropping it from the rendered message. return (
{ - setImageOffset((current) => current + direction); - }, []); + const navigateImage = useCallback( + (direction: -1 | 1) => { + setImageOffset( + (current) => (current + direction + preview.images.length) % preview.images.length, + ); + }, + [preview.images.length], + ); // The element that opened the preview gets focus back on close. Without // this a close button click leaves focus on the unmounted dialog, and the @@ -161,13 +166,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ {preview.images.length > 1 && ( )} @@ -209,7 +214,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onError={() => setFailedImageSrc(item.src)} /> )} -
+
{item.name} {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""} @@ -247,13 +252,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ {preview.images.length > 1 && ( )}
, diff --git a/apps/web/src/components/chat/markdownImageGallery.ts b/apps/web/src/components/chat/markdownImageGallery.ts new file mode 100644 index 0000000000000000000000000000000000000000..b462f23c84b2b99d215434ed529485d65285a3de --- /dev/null +++ b/apps/web/src/components/chat/markdownImageGallery.ts @@ -0,0 +1,51 @@ +import { mediaKindFromPath } from "@t3tools/shared/filePreview"; +import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; +import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview"; +import { resolveExternalWebLinkHost } from "./externalLinkContextMenu"; +import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent"; + +// Weak keys retain resolved media actions only while the rendered image is reachable. +export const markdownImageItems = new WeakMap(); + +/** Collect in document order only when opened, including PR sections separated by videos. */ +export function markdownImageGallery( + element: Element, + selected: ExpandedImageItem, +): ExpandedImagePreview { + const scope = element.closest("[data-image-gallery]") ?? element.closest(".chat-markdown"); + const images: ExpandedImageItem[] = []; + let index = -1; + for (const image of scope?.querySelectorAll("img") ?? []) { + const registered = markdownImageItems.get(image); + if (!registered) continue; + const link = image.closest("a"); + const href = link?.getAttribute("href") ?? ""; + if (link && mediaKindFromPath(href) !== "image") continue; + const linkedSource = + resolveExternalWebLinkHost(href) !== null ? resolveProtocolRelativeMediaUrl(href) : null; + const reference = mediaUrlReference(href); + const item = linkedSource + ? { + ...registered, + src: linkedSource, + originalUrl: href, + actionsSource: { + kind: "image" as const, + name: registered.name, + src: linkedSource, + ...(reference ? { reference } : {}), + }, + } + : registered; + if ( + image === element || + (!markdownImageItems.has(element) && index < 0 && item.src === selected.src) + ) { + index = images.length; + images.push(selected); + } else { + images.push(item); + } + } + return index < 0 ? { images: [selected], index: 0 } : { images, index }; +} diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx index fb084c7ce9c1952ecce82c0c9c585df5078285d1..edfc1c19f104a17693f2e9dea12897edfeac2546 100644 --- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx @@ -39,7 +39,7 @@ export function PullRequestMarkdown({ [repositoryUrl], ); return ( -
+
{segments.map((segment) => { if (segment.kind === "markdown") { return ( diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index bec3e73073354a0a5a2ffd7f1ef3e75ba972c2ae..0e688db8376e2453ec4417264ebca31a608688c5 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -51,6 +51,7 @@ const buttonVariants = cva( link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", outline: "[--control-icon-color:var(--contrast-muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64", + overlay: "border-transparent bg-black/70 text-white/65 [:hover,[data-pressed]]:bg-black/90", secondary: "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90", "warning-outline": -- 2.54.0