| ... | @@ -52,6 +52,7 @@ import React, { | ... | @@ -52,6 +52,7 @@ import React, { |
| 52 | Children, | 52 | Children, |
| 53 | Suspense, | 53 | Suspense, |
| 54 | type CSSProperties, | 54 | type CSSProperties, |
| | 55 | type ComponentProps, |
| 55 | type ClipboardEvent as ReactClipboardEvent, | 56 | type ClipboardEvent as ReactClipboardEvent, |
| 56 | type KeyboardEvent as ReactKeyboardEvent, | 57 | type KeyboardEvent as ReactKeyboardEvent, |
| 57 | type MouseEvent as ReactMouseEvent, | 58 | type MouseEvent as ReactMouseEvent, |
| ... | @@ -357,6 +358,55 @@ type MarkdownImageHastNode = { | ... | @@ -357,6 +358,55 @@ type MarkdownImageHastNode = { |
| 357 | children?: MarkdownImageHastNode[]; | 358 | children?: MarkdownImageHastNode[]; |
| 358 | }; | 359 | }; |
| 359 | | 360 | |
| | 361 | function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] { |
| | 362 | return (node.children ?? []).filter( |
| | 363 | (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""), |
| | 364 | ); |
| | 365 | } |
| | 366 | |
| | 367 | /** |
| | 368 | * An image that is the only content of its block (optionally wrapped in a |
| | 369 | * link) is almost always a screenshot or figure, so it gets a reserved slot |
| | 370 | * while it loads. Images mixed with text or other images — badge rows, icons |
| | 371 | * in a sentence — stay inline at their natural size, since a placeholder taller |
| | 372 | * than the image would move the page more than the image itself does. |
| | 373 | */ |
| | 374 | /** Containers whose sole child image reads as a figure rather than part of a sentence. */ |
| | 375 | const STANDALONE_IMAGE_BLOCKS = new Set([ |
| | 376 | "p", |
| | 377 | "div", |
| | 378 | "li", |
| | 379 | "td", |
| | 380 | "th", |
| | 381 | "figure", |
| | 382 | "center", |
| | 383 | "blockquote", |
| | 384 | ]); |
| | 385 | |
| | 386 | function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined { |
| | 387 | const children = meaningfulHastChildren(node); |
| | 388 | if (children.length !== 1) return undefined; |
| | 389 | const only = children[0]; |
| | 390 | if (only?.type !== "element") return undefined; |
| | 391 | if (only.tagName === "img") return only; |
| | 392 | // A link, emphasis, or similar inline wrapper around the image still counts |
| | 393 | // as long as nothing else shares the block. |
| | 394 | return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" |
| | 395 | ? soleImageDescendant(only) |
| | 396 | : undefined; |
| | 397 | } |
| | 398 | |
| | 399 | function markStandaloneImages(node: MarkdownImageHastNode) { |
| | 400 | // A raw `<img>` on its own line reaches the root without a paragraph. |
| | 401 | if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { |
| | 402 | const image = soleImageDescendant(node); |
| | 403 | if (image) image.properties = { ...image.properties, dataStandalone: true }; |
| | 404 | } |
| | 405 | node.children?.forEach((child) => { |
| | 406 | if (child.type === "element") markStandaloneImages(child); |
| | 407 | }); |
| | 408 | } |
| | 409 | |
| 360 | /** Carries authored image source metadata through the sanitizer to the image renderer. */ | 410 | /** Carries authored image source metadata through the sanitizer to the image renderer. */ |
| 361 | function rehypePreserveImageSourceMeta() { | 411 | function rehypePreserveImageSourceMeta() { |
| 362 | return (tree: MarkdownImageHastNode) => { | 412 | return (tree: MarkdownImageHastNode) => { |
| ... | @@ -374,6 +424,7 @@ function rehypePreserveImageSourceMeta() { | ... | @@ -374,6 +424,7 @@ function rehypePreserveImageSourceMeta() { |
| 374 | }; | 424 | }; |
| 375 | | 425 | |
| 376 | visit(tree); | 426 | visit(tree); |
| | 427 | markStandaloneImages(tree); |
| 377 | }; | 428 | }; |
| 378 | } | 429 | } |
| 379 | | 430 | |
| ... | @@ -386,7 +437,12 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { | ... | @@ -386,7 +437,12 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 386 | blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], | 437 | blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], |
| 387 | div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], | 438 | div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], |
| 388 | a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], | 439 | a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], |
| 389 | img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], | 440 | img: [ |
| | 441 | ...(defaultSchema.attributes?.img ?? []), |
| | 442 | "dataLocalSrc", |
| | 443 | "dataMarkdownTitle", |
| | 444 | "dataStandalone", |
| | 445 | ], |
| 390 | }, | 446 | }, |
| 391 | protocols: { | 447 | protocols: { |
| 392 | ...defaultSchema.protocols, | 448 | ...defaultSchema.protocols, |
| ... | @@ -1200,7 +1256,6 @@ function authoredImageSizeStyle( | ... | @@ -1200,7 +1256,6 @@ function authoredImageSizeStyle( |
| 1200 | } | 1256 | } |
| 1201 | | 1257 | |
| 1202 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( | 1258 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( |
| 1203 | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, | | |
| 1204 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, | 1259 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, |
| 1205 | CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, | 1260 | CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, |
| 1206 | ); | 1261 | ); |
| ... | @@ -1242,13 +1297,26 @@ function expandableMarkdownImageProps( | ... | @@ -1242,13 +1297,26 @@ function expandableMarkdownImageProps( |
| 1242 | }; | 1297 | }; |
| 1243 | } | 1298 | } |
| 1244 | | 1299 | |
| | 1300 | function ChatMarkdownMediaUnavailableLabel(props: { |
| | 1301 | readonly alt: string; |
| | 1302 | readonly kind?: "image" | "video" | undefined; |
| | 1303 | }) { |
| | 1304 | const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; |
| | 1305 | return ( |
| | 1306 | <span className="inline-flex items-center gap-1.5"> |
| | 1307 | <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" /> |
| | 1308 | {props.alt.length > 0 ? `${label} · ${props.alt}` : label} |
| | 1309 | </span> |
| | 1310 | ); |
| | 1311 | } |
| | 1312 | |
| | 1313 | /** Inline chip for an image that sits in a line of text or can never load. */ |
| 1245 | function ChatMarkdownImageFallback(props: { | 1314 | function ChatMarkdownImageFallback(props: { |
| 1246 | readonly alt: string; | 1315 | readonly alt: string; |
| 1247 | readonly copyMarkdown?: string | undefined; | 1316 | readonly copyMarkdown?: string | undefined; |
| 1248 | readonly kind?: "image" | "video"; | 1317 | readonly kind?: "image" | "video"; |
| 1249 | readonly actionsSource?: MediaActionSource; | 1318 | readonly actionsSource?: MediaActionSource | undefined; |
| 1250 | }) { | 1319 | }) { |
| 1251 | const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; | | |
| 1252 | const content = ( | 1320 | const content = ( |
| 1253 | <span | 1321 | <span |
| 1254 | data-markdown-copy={props.copyMarkdown} | 1322 | data-markdown-copy={props.copyMarkdown} |
| ... | @@ -1257,10 +1325,7 @@ function ChatMarkdownImageFallback(props: { | ... | @@ -1257,10 +1325,7 @@ function ChatMarkdownImageFallback(props: { |
| 1257 | "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground", | 1325 | "rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground", |
| 1258 | )} | 1326 | )} |
| 1259 | > | 1327 | > |
| 1260 | <span className="inline-flex items-center gap-1.5"> | 1328 | <ChatMarkdownMediaUnavailableLabel alt={props.alt} kind={props.kind} /> |
| 1261 | <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" /> | | |
| 1262 | {props.alt.length > 0 ? `${label} · ${props.alt}` : label} | | |
| 1263 | </span> | | |
| 1264 | </span> | 1329 | </span> |
| 1265 | ); | 1330 | ); |
| 1266 | return props.actionsSource ? ( | 1331 | return props.actionsSource ? ( |
| ... | @@ -1270,6 +1335,144 @@ function ChatMarkdownImageFallback(props: { | ... | @@ -1270,6 +1335,144 @@ function ChatMarkdownImageFallback(props: { |
| 1270 | ); | 1335 | ); |
| 1271 | } | 1336 | } |
| 1272 | | 1337 | |
| | 1338 | const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( |
| | 1339 | "aspect-video w-full overflow-hidden bg-muted/60", |
| | 1340 | CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, |
| | 1341 | CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, |
| | 1342 | ); |
| | 1343 | |
| | 1344 | /** |
| | 1345 | * A standalone image holds a 16:9 slot (or its authored size) until it has |
| | 1346 | * decoded, and keeps that slot if it fails, so a timeline row moves at most |
| | 1347 | * once: when the natural size arrives. A bare `<img>` is zero height until |
| | 1348 | * then. Once decoded the image renders bare again so its box, hit area, and |
| | 1349 | * alignment are exactly the image's own. Inline images (badges, icons in a |
| | 1350 | * sentence) skip the slot: a placeholder taller than the image would move the |
| | 1351 | * page more than the image does. |
| | 1352 | * |
| | 1353 | * Callers key this on the file's identity, not its URL: a re-signed URL for |
| | 1354 | * the same file keeps the decoded image on screen while the new bytes arrive, |
| | 1355 | * and a different file starts from the slot again. |
| | 1356 | */ |
| | 1357 | function ChatMarkdownImage(props: { |
| | 1358 | /** Null while the URL is being resolved; the last decoded image stays up. */ |
| | 1359 | readonly src: string | null; |
| | 1360 | readonly sourceFailed?: boolean | undefined; |
| | 1361 | readonly alt: string; |
| | 1362 | readonly copyMarkdown: string | undefined; |
| | 1363 | readonly standalone: boolean; |
| | 1364 | readonly className?: string | undefined; |
| | 1365 | readonly style?: CSSProperties | undefined; |
| | 1366 | /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */ |
| | 1367 | readonly imageProps?: |
| | 1368 | | Omit<ComponentProps<"img">, "src" | "alt" | "className" | "style"> |
| | 1369 | | undefined; |
| | 1370 | readonly actionsSource: MediaActionSource; |
| | 1371 | readonly originalUrl?: string | undefined; |
| | 1372 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| | 1373 | }) { |
| | 1374 | const [loadedSrc, setLoadedSrc] = useState<string | null>(null); |
| | 1375 | const [failedSrc, setFailedSrc] = useState<string | null>(null); |
| | 1376 | const src = props.src ?? loadedSrc; |
| | 1377 | const failed = props.sourceFailed === true || (src !== null && failedSrc === src); |
| | 1378 | // A failure forgets the decoded image so the next URL loads behind the slot. |
| | 1379 | const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); |
| | 1380 | // Cached images are complete before `onLoad` can fire. |
| | 1381 | const markLoadedIfComplete = useCallback((image: HTMLImageElement | null) => { |
| | 1382 | if (image?.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); |
| | 1383 | }, []); |
| | 1384 | const imageEvents = (loadingSrc: string) => ({ |
| | 1385 | onLoad: () => { |
| | 1386 | setLoadedSrc(loadingSrc); |
| | 1387 | setFailedSrc(null); |
| | 1388 | }, |
| | 1389 | onError: () => { |
| | 1390 | setFailedSrc(loadingSrc); |
| | 1391 | setLoadedSrc(null); |
| | 1392 | }, |
| | 1393 | }); |
| | 1394 | |
| | 1395 | if (settled) { |
| | 1396 | return ( |
| | 1397 | <MediaActions source={props.actionsSource}> |
| | 1398 | <img |
| | 1399 | {...props.imageProps} |
| | 1400 | ref={markLoadedIfComplete} |
| | 1401 | src={src} |
| | 1402 | alt={props.alt} |
| | 1403 | data-markdown-copy={props.copyMarkdown} |
| | 1404 | decoding="async" |
| | 1405 | draggable={false} |
| | 1406 | className={cn( |
| | 1407 | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, |
| | 1408 | props.className, |
| | 1409 | props.onImageExpand && "cursor-zoom-in", |
| | 1410 | )} |
| | 1411 | style={props.style} |
| | 1412 | {...expandableMarkdownImageProps( |
| | 1413 | props.onImageExpand, |
| | 1414 | src, |
| | 1415 | props.alt, |
| | 1416 | props.originalUrl, |
| | 1417 | props.actionsSource, |
| | 1418 | )} |
| | 1419 | {...imageEvents(src)} |
| | 1420 | /> |
| | 1421 | </MediaActions> |
| | 1422 | ); |
| | 1423 | } |
| | 1424 | if (!props.standalone) { |
| | 1425 | return failed ? ( |
| | 1426 | <ChatMarkdownImageFallback |
| | 1427 | alt={props.alt} |
| | 1428 | copyMarkdown={props.copyMarkdown} |
| | 1429 | actionsSource={props.actionsSource} |
| | 1430 | /> |
| | 1431 | ) : ( |
| | 1432 | <span |
| | 1433 | id={props.imageProps?.id} |
| | 1434 | data-markdown-copy={props.copyMarkdown} |
| | 1435 | role="status" |
| | 1436 | aria-label="Loading image" |
| | 1437 | className={CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME} |
| | 1438 | /> |
| | 1439 | ); |
| | 1440 | } |
| | 1441 | return ( |
| | 1442 | <MediaActions source={props.actionsSource}> |
| | 1443 | <span |
| | 1444 | id={props.imageProps?.id} |
| | 1445 | data-markdown-copy={props.copyMarkdown} |
| | 1446 | className={cn( |
| | 1447 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, |
| | 1448 | CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME, |
| | 1449 | "relative", |
| | 1450 | )} |
| | 1451 | style={props.style} |
| | 1452 | {...(failed |
| | 1453 | ? { role: "alert" as const } |
| | 1454 | : { role: "status" as const, "aria-label": "Loading image" })} |
| | 1455 | > |
| | 1456 | {failed ? ( |
| | 1457 | <span className="flex size-full items-center justify-center p-2 text-center text-xs text-muted-foreground"> |
| | 1458 | <ChatMarkdownMediaUnavailableLabel alt={props.alt} /> |
| | 1459 | </span> |
| | 1460 | ) : src !== null ? ( |
| | 1461 | <img |
| | 1462 | ref={markLoadedIfComplete} |
| | 1463 | src={src} |
| | 1464 | alt={props.alt} |
| | 1465 | decoding="async" |
| | 1466 | draggable={false} |
| | 1467 | className="invisible absolute inset-0 size-full" |
| | 1468 | {...imageEvents(src)} |
| | 1469 | /> |
| | 1470 | ) : null} |
| | 1471 | </span> |
| | 1472 | </MediaActions> |
| | 1473 | ); |
| | 1474 | } |
| | 1475 | |
| 1273 | function ChatMarkdownVideo(props: { | 1476 | function ChatMarkdownVideo(props: { |
| 1274 | readonly src: string | null; | 1477 | readonly src: string | null; |
| 1275 | readonly alt: string; | 1478 | readonly alt: string; |
| ... | @@ -1316,13 +1519,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props | ... | @@ -1316,13 +1519,14 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1316 | readonly alt: string; | 1519 | readonly alt: string; |
| 1317 | readonly copyMarkdown?: string; | 1520 | readonly copyMarkdown?: string; |
| 1318 | readonly srcFragment?: string; | 1521 | readonly srcFragment?: string; |
| | 1522 | /** Reserve a slot while loading; off for images that share a line with text. */ |
| | 1523 | readonly standalone?: boolean | undefined; |
| 1319 | readonly style?: CSSProperties | undefined; | 1524 | readonly style?: CSSProperties | undefined; |
| 1320 | readonly workspaceRoot?: string | undefined; | 1525 | readonly workspaceRoot?: string | undefined; |
| 1321 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; | 1526 | readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; |
| 1322 | }) { | 1527 | }) { |
| 1323 | const assetUrl = useAssetUrlState(props.environmentId, props.resource); | 1528 | const assetUrl = useAssetUrlState(props.environmentId, props.resource); |
| 1324 | const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); | 1529 | const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); |
| 1325 | const [failedUrl, setFailedUrl] = useState<string | null>(null); | | |
| 1326 | const resource = props.resource; | 1530 | const resource = props.resource; |
| 1327 | const path = | 1531 | const path = |
| 1328 | resource._tag === "media-file" | 1532 | resource._tag === "media-file" |
| ... | @@ -1367,56 +1571,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props | ... | @@ -1367,56 +1571,19 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props |
| 1367 | ); | 1571 | ); |
| 1368 | } | 1572 | } |
| 1369 | | 1573 | |
| 1370 | if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { | | |
| 1371 | return ( | | |
| 1372 | <ChatMarkdownImageFallback | | |
| 1373 | alt={props.alt} | | |
| 1374 | copyMarkdown={props.copyMarkdown} | | |
| 1375 | kind={props.kind ?? "image"} | | |
| 1376 | actionsSource={actionsSource} | | |
| 1377 | /> | | |
| 1378 | ); | | |
| 1379 | } | | |
| 1380 | if (assetUrl._tag !== "Success") { | | |
| 1381 | return ( | | |
| 1382 | <MediaActions source={actionsSource}> | | |
| 1383 | <span | | |
| 1384 | data-markdown-copy={props.copyMarkdown} | | |
| 1385 | role="status" | | |
| 1386 | aria-label="Loading image" | | |
| 1387 | className={cn( | | |
| 1388 | CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, | | |
| 1389 | "aspect-video w-64 max-w-full rounded-lg bg-muted/60", | | |
| 1390 | CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, | | |
| 1391 | )} | | |
| 1392 | style={props.style} | | |
| 1393 | /> | | |
| 1394 | </MediaActions> | | |
| 1395 | ); | | |
| 1396 | } | | |
| 1397 | return ( | 1574 | return ( |
| 1398 | <MediaActions source={actionsSource}> | 1575 | <ChatMarkdownImage |
| 1399 | <img | 1576 | key={JSON.stringify([props.environmentId, props.resource, props.srcFragment])} |
| 1400 | src={src ?? undefined} | 1577 | src={src} |
| 1401 | alt={props.alt} | 1578 | sourceFailed={assetUrl._tag === "Failure"} |
| 1402 | data-markdown-copy={props.copyMarkdown} | 1579 | alt={props.alt} |
| 1403 | loading="lazy" | 1580 | copyMarkdown={props.copyMarkdown} |
| 1404 | draggable={false} | 1581 | standalone={props.standalone ?? true} |
| 1405 | className={cn( | 1582 | className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME} |
| 1406 | CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME, | 1583 | style={props.style} |
| 1407 | props.onImageExpand && "cursor-zoom-in", | 1584 | actionsSource={actionsSource} |
| 1408 | )} | 1585 | onImageExpand={props.onImageExpand} |
| 1409 | style={props.style} | 1586 | /> |
| 1410 | {...expandableMarkdownImageProps( | | |
| 1411 | props.onImageExpand, | | |
| 1412 | src ?? assetUrl.url, | | |
| 1413 | props.alt, | | |
| 1414 | undefined, | | |
| 1415 | actionsSource, | | |
| 1416 | )} | | |
| 1417 | onError={() => setFailedUrl(assetUrl.url)} | | |
| 1418 | /> | | |
| 1419 | </MediaActions> | | |
| 1420 | ); | 1587 | ); |
| 1421 | }); | 1588 | }); |
| 1422 | | 1589 | |
| ... | @@ -2772,6 +2939,7 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2772,6 +2939,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2772 | const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; | 2939 | const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; |
| 2773 | const localSrc = node?.properties?.dataLocalSrc; | 2940 | const localSrc = node?.properties?.dataLocalSrc; |
| 2774 | const markdownTitle = node?.properties?.dataMarkdownTitle; | 2941 | const markdownTitle = node?.properties?.dataMarkdownTitle; |
| | 2942 | const standalone = node?.properties?.dataStandalone === true; |
| 2775 | const authoredSrc = typeof localSrc === "string" ? localSrc : src; | 2943 | const authoredSrc = typeof localSrc === "string" ? localSrc : src; |
| 2776 | const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; | 2944 | const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; |
| 2777 | const srcString = | 2945 | const srcString = |
| ... | @@ -2780,7 +2948,8 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2780,7 +2948,8 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2780 | typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString; | 2948 | typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString; |
| 2781 | const altText = alt ?? ""; | 2949 | const altText = alt ?? ""; |
| 2782 | const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); | 2950 | const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); |
| 2783 | const authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); | 2951 | const { className, style: _style, width, height, ...imageProps } = props; |
| | 2952 | const authoredSizeStyle = authoredImageSizeStyle(width, height); |
| 2784 | const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); | 2953 | const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); |
| 2785 | const kind = mediaKindFromPath(classifiedSrc) ?? "image"; | 2954 | const kind = mediaKindFromPath(classifiedSrc) ?? "image"; |
| 2786 | if (imageSource._tag === "Direct") { | 2955 | if (imageSource._tag === "Direct") { |
| ... | @@ -2807,27 +2976,19 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2807,27 +2976,19 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2807 | ); | 2976 | ); |
| 2808 | } | 2977 | } |
| 2809 | return ( | 2978 | return ( |
| 2810 | <MediaActions source={actionsSource}> | 2979 | <ChatMarkdownImage |
| 2811 | <img | 2980 | key={mediaSrc} |
| 2812 | {...props} | 2981 | src={mediaSrc} |
| 2813 | src={mediaSrc} | 2982 | alt={altText} |
| 2814 | alt={altText} | 2983 | copyMarkdown={copyMarkdown} |
| 2815 | loading="lazy" | 2984 | standalone={standalone} |
| 2816 | className={cn( | 2985 | className={className} |
| 2817 | props.className, | 2986 | style={authoredSizeStyle} |
| 2818 | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, | 2987 | imageProps={imageProps} |
| 2819 | imageExpand && "cursor-zoom-in", | 2988 | actionsSource={actionsSource} |
| 2820 | )} | 2989 | originalUrl={originalUrl} |
| 2821 | style={authoredSizeStyle} | 2990 | onImageExpand={imageExpand} |
| 2822 | {...expandableMarkdownImageProps( | 2991 | /> |
| 2823 | imageExpand, | | |
| 2824 | mediaSrc, | | |
| 2825 | altText, | | |
| 2826 | originalUrl, | | |
| 2827 | actionsSource, | | |
| 2828 | )} | | |
| 2829 | /> | | |
| 2830 | </MediaActions> | | |
| 2831 | ); | 2992 | ); |
| 2832 | } | 2993 | } |
| 2833 | if (imageSource._tag === "WorkspaceFile" && threadRef) { | 2994 | if (imageSource._tag === "WorkspaceFile" && threadRef) { |
| ... | @@ -2843,6 +3004,7 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2843,6 +3004,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2843 | kind={kind} | 3004 | kind={kind} |
| 2844 | copyMarkdown={copyMarkdown} | 3005 | copyMarkdown={copyMarkdown} |
| 2845 | srcFragment={markdownImageSourceFragment(classifiedSrc)} | 3006 | srcFragment={markdownImageSourceFragment(classifiedSrc)} |
| | 3007 | standalone={standalone} |
| 2846 | style={authoredSizeStyle} | 3008 | style={authoredSizeStyle} |
| 2847 | workspaceRoot={cwd} | 3009 | workspaceRoot={cwd} |
| 2848 | onImageExpand={imageExpand} | 3010 | onImageExpand={imageExpand} |