| author | |
| committer | |
| log | 6df0add6e65b7c0040b5774635f794e4ff802ddd |
| tree | 28d0ef2eb9d65e517ac820d7a2983066e0e5f21d |
| parent | 5a18fb95e49ea8352963329f32d5e85cf3125e24 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 107 insertions(+), 40 deletions(-)
apps/web/src/components/ChatMarkdown.tsx+37-27| ... | ... | @@ -88,6 +88,7 @@ import { |
| 88 | 88 | type ExpandedImagePreview, |
| 89 | 89 | } from "./chat/ExpandedImagePreview"; |
| 90 | 90 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 91 | import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; | |
| 91 | 92 | import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; |
| 92 | 93 | import { MediaActions, type MediaActionSource } from "./media/MediaActions"; |
| 93 | 94 | import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; |
| ... | ... | @@ -1283,10 +1284,7 @@ const MarkdownLinkContext = React.createContext(false); |
| 1283 | 1284 | |
| 1284 | 1285 | function expandableMarkdownImageProps( |
| 1285 | 1286 | onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, |
| 1286 | src: string, | |
| 1287 | 1287 | alt: string, |
| 1288 | originalUrl?: string, | |
| 1289 | actionsSource?: MediaActionSource, | |
| 1290 | 1288 | ) { |
| 1291 | 1289 | if (!onImageExpand) return {}; |
| 1292 | 1290 | const previewName = alt.trim() || "image"; |
| ... | ... | @@ -1294,17 +1292,8 @@ function expandableMarkdownImageProps( |
| 1294 | 1292 | if (event.currentTarget.closest("a")) return; |
| 1295 | 1293 | event.preventDefault(); |
| 1296 | 1294 | 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)); | |
| 1308 | 1297 | }; |
| 1309 | 1298 | return { |
| 1310 | 1299 | role: "button" as const, |
| ... | ... | @@ -1398,9 +1387,19 @@ function ChatMarkdownImage(props: { |
| 1398 | 1387 | // A failure forgets the decoded image so the next URL loads behind the slot. |
| 1399 | 1388 | const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); |
| 1400 | 1389 | // 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 | ); | |
| 1404 | 1403 | const imageEvents = (loadingSrc: string) => ({ |
| 1405 | 1404 | onLoad: () => { |
| 1406 | 1405 | setLoadedSrc(loadingSrc); |
| ... | ... | @@ -1429,13 +1428,7 @@ function ChatMarkdownImage(props: { |
| 1429 | 1428 | props.onImageExpand && "cursor-zoom-in", |
| 1430 | 1429 | )} |
| 1431 | 1430 | 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)} | |
| 1439 | 1432 | {...imageEvents(src)} |
| 1440 | 1433 | /> |
| 1441 | 1434 | </MediaActions> |
| ... | ... | @@ -2173,6 +2166,7 @@ function useChatMarkdownState({ |
| 2173 | 2166 | }: ChatMarkdownProps) { |
| 2174 | 2167 | const { resolvedTheme } = useTheme(); |
| 2175 | 2168 | const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null); |
| 2169 | const markdownRef = useRef<HTMLDivElement>(null); | |
| 2176 | 2170 | const expandMedia = onImageExpand ?? setLocalMediaPreview; |
| 2177 | 2171 | const mediaRequestId = useRef(0); |
| 2178 | 2172 | useEffect(() => { |
| ... | ... | @@ -2203,7 +2197,7 @@ function useChatMarkdownState({ |
| 2203 | 2197 | ); |
| 2204 | 2198 | const preparedConnection = usePreparedConnection(environmentId); |
| 2205 | 2199 | const openMarkdownMedia = useCallback( |
| 2206 | (source: string, resolvedFilePath?: string) => { | |
| 2200 | (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => { | |
| 2207 | 2201 | const requestId = ++mediaRequestId.current; |
| 2208 | 2202 | void resolveMarkdownMediaPreview({ |
| 2209 | 2203 | source, |
| ... | ... | @@ -2218,7 +2212,14 @@ function useChatMarkdownState({ |
| 2218 | 2212 | : undefined, |
| 2219 | 2213 | }).then( |
| 2220 | 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 | 2224 | (error: unknown) => { |
| 2224 | 2225 | if (mediaRequestId.current !== requestId) return; |
| ... | ... | @@ -2625,6 +2626,7 @@ function useChatMarkdownState({ |
| 2625 | 2626 | return { |
| 2626 | 2627 | componentState, |
| 2627 | 2628 | handleCopy, |
| 2629 | markdownRef, | |
| 2628 | 2630 | markdownUrlTransform, |
| 2629 | 2631 | localMediaPreview, |
| 2630 | 2632 | setLocalMediaPreview, |
| ... | ... | @@ -2812,7 +2814,13 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2812 | 2814 | ) { |
| 2813 | 2815 | event.preventDefault(); |
| 2814 | 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 | 2824 | return; |
| 2817 | 2825 | } |
| 2818 | 2826 | // A link to a change request in a workspace project opens beside the |
| ... | ... | @@ -3116,6 +3124,7 @@ function ChatMarkdown({ |
| 3116 | 3124 | const { |
| 3117 | 3125 | componentState, |
| 3118 | 3126 | handleCopy, |
| 3127 | markdownRef, | |
| 3119 | 3128 | markdownUrlTransform, |
| 3120 | 3129 | localMediaPreview, |
| 3121 | 3130 | setLocalMediaPreview, |
| ... | ... | @@ -3133,6 +3142,7 @@ function ChatMarkdown({ |
| 3133 | 3142 | // complete source token instead of dropping it from the rendered message. |
| 3134 | 3143 | return ( |
| 3135 | 3144 | <div |
| 3145 | ref={markdownRef} | |
| 3136 | 3146 | className={cn( |
| 3137 | 3147 | "chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80 [overflow-wrap:anywhere] [word-break:break-word]", |
| 3138 | 3148 | className, |
apps/web/src/components/chat/ExpandedImageDialog.tsx+17-12| ... | ... | @@ -83,9 +83,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 83 | 83 | } |
| 84 | 84 | : source; |
| 85 | 85 | |
| 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 | ); | |
| 89 | 94 | |
| 90 | 95 | // The element that opened the preview gets focus back on close. Without |
| 91 | 96 | // this a close button click leaves focus on the unmounted dialog, and the |
| ... | ... | @@ -161,13 +166,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 161 | 166 | {preview.images.length > 1 && ( |
| 162 | 167 | <Button |
| 163 | 168 | 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" | |
| 167 | 172 | aria-label="Previous image" |
| 168 | 173 | onClick={() => navigateImage(-1)} |
| 169 | 174 | > |
| 170 | <ChevronLeftIcon className="size-5" /> | |
| 175 | <ChevronLeftIcon className="size-7" /> | |
| 171 | 176 | </Button> |
| 172 | 177 | )} |
| 173 | 178 | <MediaActions source={actionsSource}> |
| ... | ... | @@ -209,7 +214,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 209 | 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 | 218 | <span className="truncate"> |
| 214 | 219 | {item.name} |
| 215 | 220 | {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""} |
| ... | ... | @@ -247,13 +252,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 247 | 252 | {preview.images.length > 1 && ( |
| 248 | 253 | <Button |
| 249 | 254 | 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" | |
| 253 | 258 | aria-label="Next image" |
| 254 | 259 | onClick={() => navigateImage(1)} |
| 255 | 260 | > |
| 256 | <ChevronRightIcon className="size-5" /> | |
| 261 | <ChevronRightIcon className="size-7" /> | |
| 257 | 262 | </Button> |
| 258 | 263 | )} |
| 259 | 264 | </div>, |
apps/web/src/components/chat/markdownImageGallery.ts created+51| ... | ... | @@ -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({ |
| 39 | 39 | [repositoryUrl], |
| 40 | 40 | ); |
| 41 | 41 | return ( |
| 42 | <div className={cn("space-y-3", className)}> | |
| 42 | <div className={cn("space-y-3", className)} data-image-gallery> | |
| 43 | 43 | {segments.map((segment) => { |
| 44 | 44 | if (segment.kind === "markdown") { |
| 45 | 45 | return ( |
apps/web/src/components/ui/button.tsx+1| ... | ... | @@ -51,6 +51,7 @@ const buttonVariants = cva( |
| 51 | 51 | link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", |
| 52 | 52 | outline: |
| 53 | 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 | 55 | secondary: |
| 55 | 56 | "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90", |
| 56 | 57 | "warning-outline": |