authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-08 00:52:07-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 00:52:07-03:00
log6df0add6e65b7c0040b5774635f794e4ff802ddd
tree28d0ef2eb9d65e517ac820d7a2983066e0e5f21d
parent5a18fb95e49ea8352963329f32d5e85cf3125e24
signature Signed by PGP key B5690EEEBB952194

fix(web): navigate markdown images as galleries (#10625)


5 files changed, 107 insertions(+), 40 deletions(-)

apps/web/src/components/ChatMarkdown.tsx+37-27
...@@ -88,6 +88,7 @@ import {...@@ -88,6 +88,7 @@ import {
88 type ExpandedImagePreview,88 type ExpandedImagePreview,
89} from "./chat/ExpandedImagePreview";89} from "./chat/ExpandedImagePreview";
90import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";90import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
91import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery";
91import { MediaVideoPlayer } from "./media/MediaVideoPlayer";92import { MediaVideoPlayer } from "./media/MediaVideoPlayer";
92import { MediaActions, type MediaActionSource } from "./media/MediaActions";93import { MediaActions, type MediaActionSource } from "./media/MediaActions";
93import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent";94import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent";
...@@ -1283,10 +1284,7 @@ const MarkdownLinkContext = React.createContext(false);...@@ -1283,10 +1284,7 @@ const MarkdownLinkContext = React.createContext(false);
12831284
1284function expandableMarkdownImageProps(1285function expandableMarkdownImageProps(
1285 onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined,1286 onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined,
1286 src: string,
1287 alt: string,1287 alt: string,
1288 originalUrl?: string,
1289 actionsSource?: MediaActionSource,
1290) {1288) {
1291 if (!onImageExpand) return {};1289 if (!onImageExpand) return {};
1292 const previewName = alt.trim() || "image";1290 const previewName = alt.trim() || "image";
...@@ -1294,17 +1292,8 @@ function expandableMarkdownImageProps(...@@ -1294,17 +1292,8 @@ function expandableMarkdownImageProps(
1294 if (event.currentTarget.closest("a")) return;1292 if (event.currentTarget.closest("a")) return;
1295 event.preventDefault();1293 event.preventDefault();
1296 event.stopPropagation();1294 event.stopPropagation();
1297 onImageExpand({1295 const item = markdownImageItems.get(event.currentTarget);
1298 images: [1296 if (item) onImageExpand(markdownImageGallery(event.currentTarget, item));
1299 {
1300 src,
1301 name: previewName,
1302 ...(originalUrl ? { originalUrl } : {}),
1303 ...(actionsSource ? { actionsSource } : {}),
1304 },
1305 ],
1306 index: 0,
1307 });
1308 };1297 };
1309 return {1298 return {
1310 role: "button" as const,1299 role: "button" as const,
...@@ -1398,9 +1387,19 @@ function ChatMarkdownImage(props: {...@@ -1398,9 +1387,19 @@ function ChatMarkdownImage(props: {
1398 // A failure forgets the decoded image so the next URL loads behind the slot.1387 // A failure forgets the decoded image so the next URL loads behind the slot.
1399 const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null);1388 const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null);
1400 // Cached images are complete before `onLoad` can fire.1389 // Cached images are complete before `onLoad` can fire.
1401 const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => {1390 const markLoadedIfComplete = useCallback(
1402 if (image?.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src);1391 (image: HTMLImageElement | null) => {
1403 }, []);1392 if (!image) return;
1393 if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src);
1394 markdownImageItems.set(image, {
1395 src,
1396 name: props.alt.trim() || "image",
1397 actionsSource: props.actionsSource,
1398 ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}),
1399 });
1400 },
1401 [props.actionsSource, props.alt, props.originalUrl, src],
1402 );
1404 const imageEvents = (loadingSrc: string) => ({1403 const imageEvents = (loadingSrc: string) => ({
1405 onLoad: () => {1404 onLoad: () => {
1406 setLoadedSrc(loadingSrc);1405 setLoadedSrc(loadingSrc);
...@@ -1429,13 +1428,7 @@ function ChatMarkdownImage(props: {...@@ -1429,13 +1428,7 @@ function ChatMarkdownImage(props: {
1429 props.onImageExpand && "cursor-zoom-in",1428 props.onImageExpand && "cursor-zoom-in",
1430 )}1429 )}
1431 style={props.style}1430 style={props.style}
1432 {...expandableMarkdownImageProps(1431 {...expandableMarkdownImageProps(props.onImageExpand, props.alt)}
1433 props.onImageExpand,
1434 src,
1435 props.alt,
1436 props.originalUrl,
1437 props.actionsSource,
1438 )}
1439 {...imageEvents(src)}1432 {...imageEvents(src)}
1440 />1433 />
1441 </MediaActions>1434 </MediaActions>
...@@ -2173,6 +2166,7 @@ function useChatMarkdownState({...@@ -2173,6 +2166,7 @@ function useChatMarkdownState({
2173}: ChatMarkdownProps) {2166}: ChatMarkdownProps) {
2174 const { resolvedTheme } = useTheme();2167 const { resolvedTheme } = useTheme();
2175 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);2168 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
2169 const markdownRef = useRef<HTMLDivElement>(null);
2176 const expandMedia = onImageExpand ?? setLocalMediaPreview;2170 const expandMedia = onImageExpand ?? setLocalMediaPreview;
2177 const mediaRequestId = useRef(0);2171 const mediaRequestId = useRef(0);
2178 useEffect(() => {2172 useEffect(() => {
...@@ -2203,7 +2197,7 @@ function useChatMarkdownState({...@@ -2203,7 +2197,7 @@ function useChatMarkdownState({
2203 );2197 );
2204 const preparedConnection = usePreparedConnection(environmentId);2198 const preparedConnection = usePreparedConnection(environmentId);
2205 const openMarkdownMedia = useCallback(2199 const openMarkdownMedia = useCallback(
2206 (source: string, resolvedFilePath?: string) => {2200 (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => {
2207 const requestId = ++mediaRequestId.current;2201 const requestId = ++mediaRequestId.current;
2208 void resolveMarkdownMediaPreview({2202 void resolveMarkdownMediaPreview({
2209 source,2203 source,
...@@ -2218,7 +2212,14 @@ function useChatMarkdownState({...@@ -2218,7 +2212,14 @@ function useChatMarkdownState({
2218 : undefined,2212 : undefined,
2219 }).then(2213 }).then(
2220 (preview) => {2214 (preview) => {
2221 if (preview && mediaRequestId.current === requestId) expandMedia(preview);2215 if (preview && mediaRequestId.current === requestId) {
2216 const selected = preview.images[preview.index];
2217 expandMedia(
2218 selected && selected.type !== "video" && markdownRef.current
2219 ? markdownImageGallery(clickedImage ?? markdownRef.current, selected)
2220 : preview,
2221 );
2222 }
2222 },2223 },
2223 (error: unknown) => {2224 (error: unknown) => {
2224 if (mediaRequestId.current !== requestId) return;2225 if (mediaRequestId.current !== requestId) return;
...@@ -2625,6 +2626,7 @@ function useChatMarkdownState({...@@ -2625,6 +2626,7 @@ function useChatMarkdownState({
2625 return {2626 return {
2626 componentState,2627 componentState,
2627 handleCopy,2628 handleCopy,
2629 markdownRef,
2628 markdownUrlTransform,2630 markdownUrlTransform,
2629 localMediaPreview,2631 localMediaPreview,
2630 setLocalMediaPreview,2632 setLocalMediaPreview,
...@@ -2812,7 +2814,13 @@ const CHAT_MARKDOWN_COMPONENTS = {...@@ -2812,7 +2814,13 @@ const CHAT_MARKDOWN_COMPONENTS = {
2812 ) {2814 ) {
2813 event.preventDefault();2815 event.preventDefault();
2814 event.stopPropagation();2816 event.stopPropagation();
2815 openMarkdownMedia(href);2817 openMarkdownMedia(
2818 href,
2819 undefined,
2820 event.target instanceof HTMLImageElement
2821 ? event.target
2822 : event.currentTarget.querySelector("img"),
2823 );
2816 return;2824 return;
2817 }2825 }
2818 // A link to a change request in a workspace project opens beside the2826 // A link to a change request in a workspace project opens beside the
...@@ -3116,6 +3124,7 @@ function ChatMarkdown({...@@ -3116,6 +3124,7 @@ function ChatMarkdown({
3116 const {3124 const {
3117 componentState,3125 componentState,
3118 handleCopy,3126 handleCopy,
3127 markdownRef,
3119 markdownUrlTransform,3128 markdownUrlTransform,
3120 localMediaPreview,3129 localMediaPreview,
3121 setLocalMediaPreview,3130 setLocalMediaPreview,
...@@ -3133,6 +3142,7 @@ function ChatMarkdown({...@@ -3133,6 +3142,7 @@ function ChatMarkdown({
3133 // complete source token instead of dropping it from the rendered message.3142 // complete source token instead of dropping it from the rendered message.
3134 return (3143 return (
3135 <div3144 <div
3145 ref={markdownRef}
3136 className={cn(3146 className={cn(
3137 "chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80 [overflow-wrap:anywhere] [word-break:break-word]",3147 "chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80 [overflow-wrap:anywhere] [word-break:break-word]",
3138 className,3148 className,
apps/web/src/components/chat/ExpandedImageDialog.tsx+17-12
...@@ -83,9 +83,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -83,9 +83,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
83 }83 }
84 : source;84 : source;
8585
86 const navigateImage = useCallback((direction: -1 | 1) => {86 const navigateImage = useCallback(
87 setImageOffset((current) => current + direction);87 (direction: -1 | 1) => {
88 }, []);88 setImageOffset(
89 (current) => (current + direction + preview.images.length) % preview.images.length,
90 );
91 },
92 [preview.images.length],
93 );
8994
90 // The element that opened the preview gets focus back on close. Without95 // The element that opened the preview gets focus back on close. Without
91 // this a close button click leaves focus on the unmounted dialog, and the96 // this a close button click leaves focus on the unmounted dialog, and the
...@@ -161,13 +166,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -161,13 +166,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
161 {preview.images.length > 1 && (166 {preview.images.length > 1 && (
162 <Button167 <Button
163 type="button"168 type="button"
164 size="icon"169 size="icon-xl"
165 variant="ghost"170 variant="overlay"
166 className="absolute left-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6"171 className="absolute left-2 top-1/2 z-20 -translate-y-1/2 sm:left-6"
167 aria-label="Previous image"172 aria-label="Previous image"
168 onClick={() => navigateImage(-1)}173 onClick={() => navigateImage(-1)}
169 >174 >
170 <ChevronLeftIcon className="size-5" />175 <ChevronLeftIcon className="size-7" />
171 </Button>176 </Button>
172 )}177 )}
173 <MediaActions source={actionsSource}>178 <MediaActions source={actionsSource}>
...@@ -209,7 +214,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -209,7 +214,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
209 onError={() => setFailedImageSrc(item.src)}214 onError={() => setFailedImageSrc(item.src)}
210 />215 />
211 )}216 )}
212 <div className="mt-2 flex max-w-[92vw] items-center justify-center gap-1.5 text-xs text-muted-foreground/80">217 <div className="mt-2 flex max-w-[92vw] items-center justify-center gap-1.5 text-xs text-white/80">
213 <span className="truncate">218 <span className="truncate">
214 {item.name}219 {item.name}
215 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}220 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
...@@ -247,13 +252,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({...@@ -247,13 +252,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
247 {preview.images.length > 1 && (252 {preview.images.length > 1 && (
248 <Button253 <Button
249 type="button"254 type="button"
250 size="icon"255 size="icon-xl"
251 variant="ghost"256 variant="overlay"
252 className="absolute right-2 top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6"257 className="absolute right-2 top-1/2 z-20 -translate-y-1/2 sm:right-6"
253 aria-label="Next image"258 aria-label="Next image"
254 onClick={() => navigateImage(1)}259 onClick={() => navigateImage(1)}
255 >260 >
256 <ChevronRightIcon className="size-5" />261 <ChevronRightIcon className="size-7" />
257 </Button>262 </Button>
258 )}263 )}
259 </div>,264 </div>,
apps/web/src/components/chat/markdownImageGallery.ts created+51
...@@ -0,0 +1,51 @@
1import { mediaKindFromPath } from "@t3tools/shared/filePreview";
2import { mediaUrlReference } from "@t3tools/client-runtime/media-reference";
3import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview";
4import { resolveExternalWebLinkHost } from "./externalLinkContextMenu";
5import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent";
6
7// Weak keys retain resolved media actions only while the rendered image is reachable.
8export const markdownImageItems = new WeakMap<Element, ExpandedImageItem>();
9
10/** Collect in document order only when opened, including PR sections separated by videos. */
11export function markdownImageGallery(
12 element: Element,
13 selected: ExpandedImageItem,
14): ExpandedImagePreview {
15 const scope = element.closest("[data-image-gallery]") ?? element.closest(".chat-markdown");
16 const images: ExpandedImageItem[] = [];
17 let index = -1;
18 for (const image of scope?.querySelectorAll("img") ?? []) {
19 const registered = markdownImageItems.get(image);
20 if (!registered) continue;
21 const link = image.closest("a");
22 const href = link?.getAttribute("href") ?? "";
23 if (link && mediaKindFromPath(href) !== "image") continue;
24 const linkedSource =
25 resolveExternalWebLinkHost(href) !== null ? resolveProtocolRelativeMediaUrl(href) : null;
26 const reference = mediaUrlReference(href);
27 const item = linkedSource
28 ? {
29 ...registered,
30 src: linkedSource,
31 originalUrl: href,
32 actionsSource: {
33 kind: "image" as const,
34 name: registered.name,
35 src: linkedSource,
36 ...(reference ? { reference } : {}),
37 },
38 }
39 : registered;
40 if (
41 image === element ||
42 (!markdownImageItems.has(element) && index < 0 && item.src === selected.src)
43 ) {
44 index = images.length;
45 images.push(selected);
46 } else {
47 images.push(item);
48 }
49 }
50 return index < 0 ? { images: [selected], index: 0 } : { images, index };
51}
apps/web/src/components/pullRequest/PullRequestMarkdown.tsx+1-1
...@@ -39,7 +39,7 @@ export function PullRequestMarkdown({...@@ -39,7 +39,7 @@ export function PullRequestMarkdown({
39 [repositoryUrl],39 [repositoryUrl],
40 );40 );
41 return (41 return (
42 <div className={cn("space-y-3", className)}>42 <div className={cn("space-y-3", className)} data-image-gallery>
43 {segments.map((segment) => {43 {segments.map((segment) => {
44 if (segment.kind === "markdown") {44 if (segment.kind === "markdown") {
45 return (45 return (
apps/web/src/components/ui/button.tsx+1
...@@ -51,6 +51,7 @@ const buttonVariants = cva(...@@ -51,6 +51,7 @@ const buttonVariants = cva(
51 link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline",51 link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline",
52 outline:52 outline:
53 "[--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",53 "[--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",
54 overlay: "border-transparent bg-black/70 text-white/65 [:hover,[data-pressed]]:bg-black/90",
54 secondary:55 secondary:
55 "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90",56 "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90",
56 "warning-outline":57 "warning-outline":