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 {
8888 type ExpandedImagePreview,
8989} from "./chat/ExpandedImagePreview";
9090import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
91import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery";
9192import { MediaVideoPlayer } from "./media/MediaVideoPlayer";
9293import { MediaActions, type MediaActionSource } from "./media/MediaActions";
9394import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent";
......@@ -1283,10 +1284,7 @@ const MarkdownLinkContext = React.createContext(false);
12831284
12841285function expandableMarkdownImageProps(
12851286 onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined,
1286 src: string,
12871287 alt: string,
1288 originalUrl?: string,
1289 actionsSource?: MediaActionSource,
12901288) {
12911289 if (!onImageExpand) return {};
12921290 const previewName = alt.trim() || "image";
......@@ -1294,17 +1292,8 @@ function expandableMarkdownImageProps(
12941292 if (event.currentTarget.closest("a")) return;
12951293 event.preventDefault();
12961294 event.stopPropagation();
1297 onImageExpand({
1298 images: [
1299 {
1300 src,
1301 name: previewName,
1302 ...(originalUrl ? { originalUrl } : {}),
1303 ...(actionsSource ? { actionsSource } : {}),
1304 },
1305 ],
1306 index: 0,
1307 });
1295 const item = markdownImageItems.get(event.currentTarget);
1296 if (item) onImageExpand(markdownImageGallery(event.currentTarget, item));
13081297 };
13091298 return {
13101299 role: "button" as const,
......@@ -1398,9 +1387,19 @@ function ChatMarkdownImage(props: {
13981387 // A failure forgets the decoded image so the next URL loads behind the slot.
13991388 const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null);
14001389 // Cached images are complete before `onLoad` can fire.
1401 const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => {
1402 if (image?.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src);
1403 }, []);
1390 const markLoadedIfComplete = useCallback(
1391 (image: HTMLImageElement | null) => {
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 );
14041403 const imageEvents = (loadingSrc: string) => ({
14051404 onLoad: () => {
14061405 setLoadedSrc(loadingSrc);
......@@ -1429,13 +1428,7 @@ function ChatMarkdownImage(props: {
14291428 props.onImageExpand && "cursor-zoom-in",
14301429 )}
14311430 style={props.style}
1432 {...expandableMarkdownImageProps(
1433 props.onImageExpand,
1434 src,
1435 props.alt,
1436 props.originalUrl,
1437 props.actionsSource,
1438 )}
1431 {...expandableMarkdownImageProps(props.onImageExpand, props.alt)}
14391432 {...imageEvents(src)}
14401433 />
14411434 </MediaActions>
......@@ -2173,6 +2166,7 @@ function useChatMarkdownState({
21732166}: ChatMarkdownProps) {
21742167 const { resolvedTheme } = useTheme();
21752168 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
2169 const markdownRef = useRef<HTMLDivElement>(null);
21762170 const expandMedia = onImageExpand ?? setLocalMediaPreview;
21772171 const mediaRequestId = useRef(0);
21782172 useEffect(() => {
......@@ -2203,7 +2197,7 @@ function useChatMarkdownState({
22032197 );
22042198 const preparedConnection = usePreparedConnection(environmentId);
22052199 const openMarkdownMedia = useCallback(
2206 (source: string, resolvedFilePath?: string) => {
2200 (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => {
22072201 const requestId = ++mediaRequestId.current;
22082202 void resolveMarkdownMediaPreview({
22092203 source,
......@@ -2218,7 +2212,14 @@ function useChatMarkdownState({
22182212 : undefined,
22192213 }).then(
22202214 (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 }
22222223 },
22232224 (error: unknown) => {
22242225 if (mediaRequestId.current !== requestId) return;
......@@ -2625,6 +2626,7 @@ function useChatMarkdownState({
26252626 return {
26262627 componentState,
26272628 handleCopy,
2629 markdownRef,
26282630 markdownUrlTransform,
26292631 localMediaPreview,
26302632 setLocalMediaPreview,
......@@ -2812,7 +2814,13 @@ const CHAT_MARKDOWN_COMPONENTS = {
28122814 ) {
28132815 event.preventDefault();
28142816 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 );
28162824 return;
28172825 }
28182826 // A link to a change request in a workspace project opens beside the
......@@ -3116,6 +3124,7 @@ function ChatMarkdown({
31163124 const {
31173125 componentState,
31183126 handleCopy,
3127 markdownRef,
31193128 markdownUrlTransform,
31203129 localMediaPreview,
31213130 setLocalMediaPreview,
......@@ -3133,6 +3142,7 @@ function ChatMarkdown({
31333142 // complete source token instead of dropping it from the rendered message.
31343143 return (
31353144 <div
3145 ref={markdownRef}
31363146 className={cn(
31373147 "chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80 [overflow-wrap:anywhere] [word-break:break-word]",
31383148 className,
apps/web/src/components/chat/ExpandedImageDialog.tsx+17-12
......@@ -83,9 +83,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
8383 }
8484 : source;
8585
86 const navigateImage = useCallback((direction: -1 | 1) => {
87 setImageOffset((current) => current + direction);
88 }, []);
86 const navigateImage = useCallback(
87 (direction: -1 | 1) => {
88 setImageOffset(
89 (current) => (current + direction + preview.images.length) % preview.images.length,
90 );
91 },
92 [preview.images.length],
93 );
8994
9095 // The element that opened the preview gets focus back on close. Without
9196 // this a close button click leaves focus on the unmounted dialog, and the
......@@ -161,13 +166,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
161166 {preview.images.length > 1 && (
162167 <Button
163168 type="button"
164 size="icon"
165 variant="ghost"
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"
169 size="icon-xl"
170 variant="overlay"
171 className="absolute left-2 top-1/2 z-20 -translate-y-1/2 sm:left-6"
167172 aria-label="Previous image"
168173 onClick={() => navigateImage(-1)}
169174 >
170 <ChevronLeftIcon className="size-5" />
175 <ChevronLeftIcon className="size-7" />
171176 </Button>
172177 )}
173178 <MediaActions source={actionsSource}>
......@@ -209,7 +214,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
209214 onError={() => setFailedImageSrc(item.src)}
210215 />
211216 )}
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">
213218 <span className="truncate">
214219 {item.name}
215220 {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
......@@ -247,13 +252,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
247252 {preview.images.length > 1 && (
248253 <Button
249254 type="button"
250 size="icon"
251 variant="ghost"
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"
255 size="icon-xl"
256 variant="overlay"
257 className="absolute right-2 top-1/2 z-20 -translate-y-1/2 sm:right-6"
253258 aria-label="Next image"
254259 onClick={() => navigateImage(1)}
255260 >
256 <ChevronRightIcon className="size-5" />
261 <ChevronRightIcon className="size-7" />
257262 </Button>
258263 )}
259264 </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({
3939 [repositoryUrl],
4040 );
4141 return (
42 <div className={cn("space-y-3", className)}>
42 <div className={cn("space-y-3", className)} data-image-gallery>
4343 {segments.map((segment) => {
4444 if (segment.kind === "markdown") {
4545 return (
apps/web/src/components/ui/button.tsx+1
......@@ -51,6 +51,7 @@ const buttonVariants = cva(
5151 link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline",
5252 outline:
5353 "[--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",
5455 secondary:
5556 "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90",
5657 "warning-outline":