| ... | ... | @@ -35,6 +35,7 @@ import React, { |
| 35 | 35 | Suspense, |
| 36 | 36 | type CSSProperties, |
| 37 | 37 | type ClipboardEvent as ReactClipboardEvent, |
| 38 | type KeyboardEvent as ReactKeyboardEvent, |
| 38 | 39 | type MouseEvent as ReactMouseEvent, |
| 39 | 40 | isValidElement, |
| 40 | 41 | use, |
| ... | ... | @@ -55,6 +56,7 @@ import remarkBreaks from "remark-breaks"; |
| 55 | 56 | import remarkGfm from "remark-gfm"; |
| 56 | 57 | import { remarkGithubAlerts } from "../markdown-github-alerts"; |
| 57 | 58 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; |
| 59 | import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; |
| 58 | 60 | import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; |
| 59 | 61 | import { PierreEntryIcon } from "./chat/PierreEntryIcon"; |
| 60 | 62 | import { |
| ... | ... | @@ -155,6 +157,7 @@ interface ChatMarkdownProps { |
| 155 | 157 | /** Parse sanitized raw HTML instead of displaying its source text. */ |
| 156 | 158 | parseRawHtml?: boolean; |
| 157 | 159 | imageBaseDir?: string | undefined; |
| 160 | onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| 158 | 161 | } |
| 159 | 162 | |
| 160 | 163 | export function canUseMarkdownFileShellActions( |
| ... | ... | @@ -1089,6 +1092,31 @@ const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( |
| 1089 | 1092 | CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME, |
| 1090 | 1093 | "rounded-lg border border-border/40", |
| 1091 | 1094 | ); |
| 1095 | const MarkdownLinkContext = React.createContext(false); |
| 1096 | |
| 1097 | function expandableMarkdownImageProps( |
| 1098 | onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, |
| 1099 | src: string, |
| 1100 | alt: string, |
| 1101 | ) { |
| 1102 | if (!onImageExpand) return {}; |
| 1103 | const previewName = alt.trim() || "image"; |
| 1104 | const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => { |
| 1105 | if (event.currentTarget.closest("a")) return; |
| 1106 | event.preventDefault(); |
| 1107 | event.stopPropagation(); |
| 1108 | onImageExpand({ images: [{ src, name: previewName }], index: 0 }); |
| 1109 | }; |
| 1110 | return { |
| 1111 | role: "button" as const, |
| 1112 | tabIndex: 0, |
| 1113 | "aria-label": `Preview ${previewName}`, |
| 1114 | onClick: expand, |
| 1115 | onKeyDown: (event: ReactKeyboardEvent) => { |
| 1116 | if (event.key === "Enter" || event.key === " ") expand(event); |
| 1117 | }, |
| 1118 | }; |
| 1119 | } |
| 1092 | 1120 | |
| 1093 | 1121 | function ChatMarkdownImageFallback(props: { |
| 1094 | 1122 | readonly alt: string; |
| ... | ... | @@ -1118,6 +1146,7 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop |
| 1118 | 1146 | readonly copyMarkdown: string; |
| 1119 | 1147 | readonly srcFragment: string; |
| 1120 | 1148 | readonly style?: CSSProperties | undefined; |
| 1149 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| 1121 | 1150 | }) { |
| 1122 | 1151 | const assetUrl = useAssetUrlState(props.threadRef.environmentId, { |
| 1123 | 1152 | _tag: "workspace-file", |
| ... | ... | @@ -1144,15 +1173,20 @@ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(prop |
| 1144 | 1173 | /> |
| 1145 | 1174 | ); |
| 1146 | 1175 | } |
| 1176 | const src = assetUrl.url + props.srcFragment; |
| 1147 | 1177 | return ( |
| 1148 | 1178 | <img |
| 1149 | | src={assetUrl.url + props.srcFragment} |
| 1179 | src={src} |
| 1150 | 1180 | alt={props.alt} |
| 1151 | 1181 | data-markdown-copy={props.copyMarkdown} |
| 1152 | 1182 | loading="lazy" |
| 1153 | 1183 | draggable={false} |
| 1154 | | className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME} |
| 1184 | className={cn( |
| 1185 | CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME, |
| 1186 | props.onImageExpand && "cursor-zoom-in", |
| 1187 | )} |
| 1155 | 1188 | style={props.style} |
| 1189 | {...expandableMarkdownImageProps(props.onImageExpand, src, props.alt)} |
| 1156 | 1190 | onError={() => setFailedUrl(assetUrl.url)} |
| 1157 | 1191 | /> |
| 1158 | 1192 | ); |
| ... | ... | @@ -1694,6 +1728,7 @@ function ChatMarkdown({ |
| 1694 | 1728 | lineBreaks = false, |
| 1695 | 1729 | parseRawHtml = true, |
| 1696 | 1730 | imageBaseDir, |
| 1731 | onImageExpand, |
| 1697 | 1732 | }: ChatMarkdownProps) { |
| 1698 | 1733 | const { resolvedTheme } = useTheme(); |
| 1699 | 1734 | const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| ... | ... | @@ -2084,6 +2119,7 @@ function ChatMarkdown({ |
| 2084 | 2119 | const isSameDocumentLink = href?.startsWith("#") ?? false; |
| 2085 | 2120 | const onClick = props.onClick; |
| 2086 | 2121 | const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); |
| 2122 | const linkChildren = <MarkdownLinkContext value>{children}</MarkdownLinkContext>; |
| 2087 | 2123 | const link = ( |
| 2088 | 2124 | <a |
| 2089 | 2125 | {...props} |
| ... | ... | @@ -2160,10 +2196,10 @@ function ChatMarkdown({ |
| 2160 | 2196 | > |
| 2161 | 2197 | {faviconHost && hastHasText(node) ? ( |
| 2162 | 2198 | <MarkdownExternalLinkContent host={faviconHost} plainText={plainHastText(node)}> |
| 2163 | | {children} |
| 2199 | {linkChildren} |
| 2164 | 2200 | </MarkdownExternalLinkContent> |
| 2165 | 2201 | ) : ( |
| 2166 | | children |
| 2202 | linkChildren |
| 2167 | 2203 | )} |
| 2168 | 2204 | </a> |
| 2169 | 2205 | ); |
| ... | ... | @@ -2205,7 +2241,8 @@ function ChatMarkdown({ |
| 2205 | 2241 | </code> |
| 2206 | 2242 | ); |
| 2207 | 2243 | }, |
| 2208 | | img({ node, title, src, alt, ...props }) { |
| 2244 | img: function MarkdownImage({ node, title, src, alt, ...props }) { |
| 2245 | const imageExpand = use(MarkdownLinkContext) ? undefined : onImageExpand; |
| 2209 | 2246 | const localSrc = node?.properties?.dataLocalSrc; |
| 2210 | 2247 | const markdownTitle = node?.properties?.dataMarkdownTitle; |
| 2211 | 2248 | const authoredSrc = typeof localSrc === "string" ? localSrc : src; |
| ... | ... | @@ -2225,8 +2262,13 @@ function ChatMarkdown({ |
| 2225 | 2262 | src={imageSource.uri} |
| 2226 | 2263 | alt={altText} |
| 2227 | 2264 | loading="lazy" |
| 2228 | | className={cn(props.className, CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME)} |
| 2265 | className={cn( |
| 2266 | props.className, |
| 2267 | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, |
| 2268 | imageExpand && "cursor-zoom-in", |
| 2269 | )} |
| 2229 | 2270 | style={authoredSizeStyle} |
| 2271 | {...expandableMarkdownImageProps(imageExpand, imageSource.uri, altText)} |
| 2230 | 2272 | /> |
| 2231 | 2273 | ); |
| 2232 | 2274 | } |
| ... | ... | @@ -2239,6 +2281,7 @@ function ChatMarkdown({ |
| 2239 | 2281 | copyMarkdown={copyMarkdown} |
| 2240 | 2282 | srcFragment={markdownImageSourceFragment(classifiedSrc)} |
| 2241 | 2283 | style={authoredSizeStyle} |
| 2284 | onImageExpand={imageExpand} |
| 2242 | 2285 | /> |
| 2243 | 2286 | ); |
| 2244 | 2287 | } |
| ... | ... | @@ -2289,6 +2332,7 @@ function ChatMarkdown({ |
| 2289 | 2332 | isStreaming, |
| 2290 | 2333 | markdownFileLinkMetaByHref, |
| 2291 | 2334 | onTaskListChange, |
| 2335 | onImageExpand, |
| 2292 | 2336 | openFileInPanel, |
| 2293 | 2337 | openInPreferredEditor, |
| 2294 | 2338 | openChangeRequestLink, |