| 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,6 +88,7 @@ import { |
| 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); | ... | @@ -1283,10 +1284,7 @@ const MarkdownLinkContext = React.createContext(false); |
| 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( | ... | @@ -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 the | 2826 | // 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 | <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, |
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; |
| 85 | 85 | ||
| 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 | ); | ||
| 89 | 94 | ||
| 90 | // The element that opened the preview gets focus back on close. Without | 95 | // 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 the | 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,13 +166,13 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ |
| 161 | {preview.images.length > 1 && ( | 166 | {preview.images.length > 1 && ( |
| 162 | <Button | 167 | <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 | <Button | 253 | <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 @@ | ||
| 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,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": |