fix: constrain claw table images

This commit is contained in:
134 2026-07-03 00:01:47 +08:00
parent 2ca0c54de2
commit c789dd7a69

View File

@ -6,7 +6,7 @@ import type {
PointerEvent as ReactPointerEvent,
ReactNode,
} from "react";
import { useRef, useState } from "react";
import { createContext, useRef, useState } from "react";
import { Streamdown } from "streamdown";
import { ImagePreview } from "@/components/image-preview";
import {
@ -15,6 +15,7 @@ import {
splitOpenClawChartBlocks,
} from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import {
Collapsible,
CollapsibleContent,
@ -78,8 +79,11 @@ type MarkdownTableHeaderCellProps = ComponentProps<"th"> & {
node?: unknown;
};
const MarkdownTableContext = createContext(false);
const TABLE_DRAG_INTERACTIVE_SELECTOR =
"a, button, input, textarea, select, summary, [role='button'], [contenteditable='true'], [data-no-drag='true']";
const IMAGE_URL_REGEX = /^https?:\/\/\S+\.(?:png|jpe?g|webp|gif|avif)(?:[?#]\S*)?$/i;
const MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/i;
function isTableDragInteractiveTarget(target: EventTarget | null) {
return (
@ -393,6 +397,124 @@ function splitMarkdownImages(content: string) {
return segments;
}
function isLikelyImageUrl(value?: string | null) {
return IMAGE_URL_REGEX.test(String(value || "").trim());
}
function getImageUrlFromTableText(value: ReactNode) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
const markdownImageMatch = trimmed.match(MARKDOWN_IMAGE_REGEX);
if (markdownImageMatch?.[2] && isLikelyImageUrl(markdownImageMatch[2])) {
return markdownImageMatch[2];
}
return isLikelyImageUrl(trimmed) ? trimmed : null;
}
function getOriginalImageUrlFromMarkdownImage(src: string, alt?: string) {
const normalizedAlt = String(alt || "");
const parseByMarker = (marker: string) => {
if (!normalizedAlt.includes(marker)) return "";
const content = normalizedAlt.slice(normalizedAlt.indexOf(marker) + marker.length);
return content.split(marker === "thumb|||" ? "|||" : marker === "thumb$$" ? "$$" : "|")[0] || "";
};
return (
parseByMarker("thumb|||") ||
parseByMarker("thumb$$") ||
parseByMarker("thumb|") ||
src
);
}
function MarkdownTableImagePreview({
alt,
src,
}: {
alt?: string;
src: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const originalSrc = getOriginalImageUrlFromMarkdownImage(src, alt);
const thumbnailSrc = buildImageProxyUrl(src, {
delivery: "proxy",
format: "webp",
quality: 82,
width: 180,
});
const largeSrc = buildImageProxyUrl(originalSrc, {
delivery: "proxy",
format: "webp",
quality: 90,
width: 1600,
});
return (
<>
<button
type="button"
className="group/table-image relative block h-24 w-24 overflow-hidden rounded-lg border border-border/70 bg-muted/40"
data-no-drag="true"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsOpen(true);
}}
title="点击查看大图"
>
<img
src={thumbnailSrc}
alt={alt || "表格图片"}
className="h-full w-full object-contain p-1 transition-transform duration-200 group-hover/table-image:scale-[1.03]"
loading="lazy"
decoding="async"
onError={(event) => {
if (event.currentTarget.src !== src) {
event.currentTarget.src = src;
}
}}
/>
</button>
{isOpen ? (
<div
className="fixed inset-0 z-[260] flex items-center justify-center bg-black/82 p-4"
data-no-drag="true"
onClick={() => setIsOpen(false)}
>
<button
type="button"
className="absolute right-4 top-4 rounded-full bg-white/12 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-white/20"
onClick={(event) => {
event.stopPropagation();
setIsOpen(false);
}}
>
关闭
</button>
<img
src={largeSrc}
alt={alt || "表格图片大图"}
className="max-h-[90vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
loading="eager"
decoding="async"
onClick={(event) => event.stopPropagation()}
onError={(event) => {
if (event.currentTarget.src !== originalSrc) {
event.currentTarget.src = originalSrc;
}
}}
/>
</div>
) : null}
</>
);
}
function normalizeSectionTitle(line: string) {
let normalized = line.trim();
if (!normalized) return "";
@ -568,29 +690,34 @@ function DownloadableMarkdownTable({
</button>
</div>
<HorizontalDragScrollArea>
<table
ref={tableRef}
className={cn(
"min-w-full table-auto border-collapse text-sm",
"[&_td]:min-w-[10rem] [&_td]:max-w-[24rem] [&_td]:align-top [&_td]:whitespace-normal [&_td]:break-words [&_td]:[overflow-wrap:anywhere] [&_td]:[word-break:break-word]",
"[&_td:first-child]:min-w-[12rem] [&_td:first-child]:max-w-[18rem] [&_th:first-child]:min-w-[12rem] [&_th:first-child]:max-w-[18rem]",
"[&_th]:min-w-[10rem] [&_th]:max-w-[22rem] [&_th]:whitespace-normal [&_th]:break-words [&_th]:[overflow-wrap:anywhere] [&_th]:[word-break:break-word]",
className
)}
{...props}
>
{children}
</table>
<MarkdownTableContext.Provider value>
<table
ref={tableRef}
className={cn(
"min-w-full table-auto border-collapse text-sm",
"[&_td]:min-w-[10rem] [&_td]:max-w-[24rem] [&_td]:align-top [&_td]:whitespace-normal [&_td]:break-words [&_td]:[overflow-wrap:anywhere] [&_td]:[word-break:break-word]",
"[&_td:first-child]:min-w-[12rem] [&_td:first-child]:max-w-[18rem] [&_th:first-child]:min-w-[12rem] [&_th:first-child]:max-w-[18rem]",
"[&_th]:min-w-[10rem] [&_th]:max-w-[22rem] [&_th]:whitespace-normal [&_th]:break-words [&_th]:[overflow-wrap:anywhere] [&_th]:[word-break:break-word]",
className
)}
{...props}
>
{children}
</table>
</MarkdownTableContext.Provider>
</HorizontalDragScrollArea>
</div>
);
}
function MarkdownTableCell({
children,
className,
node: _node,
...props
}: MarkdownTableCellProps) {
const imageUrl = getImageUrlFromTableText(children);
return (
<td
className={cn(
@ -598,7 +725,9 @@ function MarkdownTableCell({
className
)}
{...props}
/>
>
{imageUrl ? <MarkdownTableImagePreview src={imageUrl} /> : children}
</td>
);
}
@ -701,23 +830,31 @@ export const Response = ({
}
return (
<ImagePreview
key={`${src}-${processingKey}`}
src={src}
alt={alt}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
onImageWatermarkRemove={onImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
/>
<MarkdownTableContext.Consumer>
{(isInsideTable) =>
isInsideTable ? (
<MarkdownTableImagePreview alt={alt} src={src} />
) : (
<ImagePreview
key={`${src}-${processingKey}`}
src={src}
alt={alt}
onImageEdit={onImageEdit}
onDrawingComplete={onDrawingComplete}
onImageEnhance={onImageEnhance}
onImageRmbg={onImageRmbg}
onImageExtend={onImageExtend}
onImageAngleEdit={onImageAngleEdit}
onImageWatermarkRemove={onImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
/>
)
}
</MarkdownTableContext.Consumer>
);
},
video: ({ src, alt }: { src?: string; alt?: string }) => {
@ -736,22 +873,33 @@ export const Response = ({
onClick,
...linkProps
}: ComponentProps<"a">) => (
<a
{...linkProps}
className={cn(
"font-medium text-primary underline underline-offset-2",
className
)}
href={href}
onClick={(event) => {
event.stopPropagation();
onClick?.(event);
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
<MarkdownTableContext.Consumer>
{(isInsideTable) =>
isInsideTable && isLikelyImageUrl(href) ? (
<MarkdownTableImagePreview
alt={typeof children === "string" ? children : undefined}
src={String(href || "")}
/>
) : (
<a
{...linkProps}
className={cn(
"font-medium text-primary underline underline-offset-2",
className
)}
href={href}
onClick={(event) => {
event.stopPropagation();
onClick?.(event);
}}
rel="noreferrer"
target="_blank"
>
{children}
</a>
)
}
</MarkdownTableContext.Consumer>
),
};
@ -778,7 +926,11 @@ export const Response = ({
const renderTextSegment = (text: string, keyPrefix: string): ReactNode => {
if (!text.trim()) return null;
if (text.includes("![") && text.includes("](")) {
if (
!MARKDOWN_TABLE_REGEX.test(text) &&
text.includes("![") &&
text.includes("](")
) {
const segments = splitMarkdownImages(text);
const hasImageSegments = segments.some((segment) => segment.type === "image");