| @@ -88,6 +88,7 @@ import { · 87 unmodified lines |
| 88 | type ExpandedImagePreview, | 88 | type ExpandedImagePreview, |
| 89 | } from "./chat/ExpandedImagePreview"; | 89 | } from "./chat/ExpandedImagePreview"; |
| 90 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; | 90 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| | 91 | import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; |
| 91 | import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; | 92 | import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; |
| 92 | import { MediaActions, type MediaActionSource } from "./media/MediaActions"; | 93 | import { MediaActions, type MediaActionSource } from "./media/MediaActions"; |
| 93 | import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; | 94 | import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; |
| @@ -1283,10 +1284,7 @@ const MarkdownLinkContext = React.createContext(false); · 1189 unmodified lines |
| 1283 | | 1284 | |
| 1284 | function expandableMarkdownImageProps( | 1285 | function 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( · 1 unmodified line |
| 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: { · 87 unmodified lines |
| 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: { · 22 unmodified lines |
| 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({ · 731 unmodified lines |
| 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({ · 24 unmodified lines |
| 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({ · 8 unmodified lines |
| 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({ · 400 unmodified lines |
| 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 = { · 181 unmodified lines |
| 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 the | 2826 | // A link to a change request in a workspace project opens beside the |
| @@ -3116,6 +3124,7 @@ function ChatMarkdown({ · 297 unmodified lines |
| 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({ · 11 unmodified lines |
| 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 | <div | 3144 | <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, |