History

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

maria committed 2 months ago

Commit 6df0add1 parent 5a18fb95 files · +107 −40Browse files

Unified · Split

5 files
  1. apps/web/src/components/ChatMarkdown.tsx +37 −27
  2. apps/web/src/components/chat/ExpandedImageDialog.tsx +17 −12
  3. apps/web/src/components/chat/markdownImageGallery.ts +51 −0
  4. apps/web/src/components/pullRequest/PullRequestMarkdown.tsx +1 −1
  5. apps/web/src/components/ui/button.tsx +1 −0
apps/web/src/components/ChatMarkdown.tsx+37−27
@@ -88,6 +88,7 @@ import { · 87 unmodified lines
8888 type ExpandedImagePreview,
8989 } from "./chat/ExpandedImagePreview";
9090 import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
91+import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery";
9192 import { MediaVideoPlayer } from "./media/MediaVideoPlayer";
9293 import { MediaActions, type MediaActionSource } from "./media/MediaActions";
9394 import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent";
@@ -1283,10 +1284,7 @@ const MarkdownLinkContext = React.createContext(false); · 1189 unmodified lines
12831284
12841285 function 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( · 1 unmodified line
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: { · 87 unmodified lines
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: { · 22 unmodified lines
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({ · 731 unmodified lines
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({ · 24 unmodified lines
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({ · 8 unmodified lines
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({ · 400 unmodified lines
26252626 return {
26262627 componentState,
26272628 handleCopy,
2629+ markdownRef,
26282630 markdownUrlTransform,
26292631 localMediaPreview,
26302632 setLocalMediaPreview,
@@ -2812,7 +2814,13 @@ const CHAT_MARKDOWN_COMPONENTS = { · 181 unmodified lines
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({ · 297 unmodified lines
31163124 const {
31173125 componentState,
31183126 handleCopy,
3127+ markdownRef,
31193128 markdownUrlTransform,
31203129 localMediaPreview,
31213130 setLocalMediaPreview,
@@ -3133,6 +3142,7 @@ function ChatMarkdown({ · 11 unmodified lines
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({ · 82 unmodified lines
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({ · 69 unmodified lines
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({ · 35 unmodified lines
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({ · 31 unmodified lines
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+51−0
@@ -0,0 +1,51 @@
1+import { mediaKindFromPath } from "@t3tools/shared/filePreview";
2+import { mediaUrlReference } from "@t3tools/client-runtime/media-reference";
3+import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview";
4+import { resolveExternalWebLinkHost } from "./externalLinkContextMenu";
5+import { resolveProtocolRelativeMediaUrl } from "../media/mediaContent";
6+
7+// Weak keys retain resolved media actions only while the rendered image is reachable.
8+export const markdownImageItems = new WeakMap<Element, ExpandedImageItem>();
9+
10+/** Collect in document order only when opened, including PR sections separated by videos. */
11+export 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({ · 38 unmodified lines
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−0
@@ -51,6 +51,7 @@ const buttonVariants = cva( · 50 unmodified lines
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":