fix: constrain claw table images
This commit is contained in:
parent
2ca0c54de2
commit
c789dd7a69
@ -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(") {
|
||||
if (
|
||||
!MARKDOWN_TABLE_REGEX.test(text) &&
|
||||
text.includes("
|
||||
) {
|
||||
const segments = splitMarkdownImages(text);
|
||||
const hasImageSegments = segments.some((segment) => segment.type === "image");
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user