修复画布预览放大交互与触控板横向滑动翻页

This commit is contained in:
Your Name 2026-02-09 00:32:11 +08:00
parent be1ad6be1d
commit 1556826c1b
4 changed files with 118 additions and 24 deletions

View File

@ -488,34 +488,64 @@ async function normalizeAssistantImageMarkdown(
if (!text || !text.includes("data:image")) return text; if (!text || !text.includes("data:image")) return text;
const markdownDataUrlRegex = /!\[([^\]]*)\]\s*\(\s*(data:image\/[a-z0-9+.-]+;base64,[^)]+)\s*\)/gim; const markdownDataUrlRegex = /!\[([^\]]*)\]\s*\(\s*(data:image\/[a-z0-9+.-]+;base64,[^)]+)\s*\)/gim;
const matches = [...text.matchAll(markdownDataUrlRegex)]; const markdownMatches = [...text.matchAll(markdownDataUrlRegex)];
if (matches.length === 0) { let normalizedText = text;
return text; const uploadedUrls: string[] = [];
}
let normalizedText = ""; if (markdownMatches.length > 0) {
let cursor = 0; let rebuilt = "";
let cursor = 0;
for (const match of matches) { for (const match of markdownMatches) {
const matchText = match[0]; const matchText = match[0];
const dataUrl = match[2]; const dataUrl = match[2];
const start = match.index ?? -1; const start = match.index ?? -1;
if (start < 0) continue; if (start < 0) continue;
normalizedText += text.slice(cursor, start); rebuilt += normalizedText.slice(cursor, start);
try { try {
const uploadedUrl = await uploadDataUrlImage(dataUrl); const uploadedUrl = await uploadDataUrlImage(dataUrl);
normalizedText += `![thumb|||${uploadedUrl}|||${imageSize}](${uploadedUrl})`; uploadedUrls.push(uploadedUrl);
} catch (error) { rebuilt += `![thumb|||${uploadedUrl}|||${imageSize}](${uploadedUrl})`;
console.error("[Chat API] Failed to upload inline data URL image:", error); } catch (error) {
normalizedText += matchText; console.error("[Chat API] Failed to upload inline data URL image:", error);
rebuilt += matchText;
}
cursor = start + matchText.length;
} }
cursor = start + matchText.length; rebuilt += normalizedText.slice(cursor);
normalizedText = rebuilt;
}
if (normalizedText.includes("data:image")) {
const rawDataUrlRegex = /data:image\/[a-z0-9+.-]+;base64,[a-z0-9+/=\s]+/gim;
const rawMatches = normalizedText.match(rawDataUrlRegex) ?? [];
for (const rawDataUrl of rawMatches) {
try {
const uploadedUrl = await uploadDataUrlImage(rawDataUrl);
uploadedUrls.push(uploadedUrl);
normalizedText = normalizedText.replace(rawDataUrl, uploadedUrl);
} catch (error) {
console.error("[Chat API] Failed to normalize raw data URL image:", error);
}
}
}
const hasMarkdownImage = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/i.test(normalizedText);
if (!hasMarkdownImage && uploadedUrls.length > 0) {
const uniqueUrls = [...new Set(uploadedUrls)];
console.log(
`[Chat API] Converted ${uniqueUrls.length} data URL image(s) to hosted link(s)`
);
return uniqueUrls
.map((url) => `![thumb|||${url}|||${imageSize}](${url})`)
.join("\n");
} }
normalizedText += text.slice(cursor);
return normalizedText; return normalizedText;
} }

View File

@ -465,7 +465,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="relative h-dvh w-full overflow-hidden bg-background"> <div className="relative h-dvh w-full overflow-hidden overscroll-none bg-background">
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */} {/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
<div className="absolute inset-0 z-0" key={`workspace-${id}`}> <div className="absolute inset-0 z-0" key={`workspace-${id}`}>

View File

@ -161,6 +161,10 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
// 🆕 标签悬停预览状态 // 🆕 标签悬停预览状态
const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null); const [tagPreview, setTagPreview] = useState<TagPreviewInfo | null>(null);
const previewTimeoutRef = useRef<NodeJS.Timeout | null>(null); const previewTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [fullscreenPreview, setFullscreenPreview] = useState<{
url: string;
alt?: string;
} | null>(null);
// 🆕 图片参数状态 // 🆕 图片参数状态
const [imageParams, setImageParams] = useState<ImageGenerationParams>({ const [imageParams, setImageParams] = useState<ImageGenerationParams>({
@ -192,6 +196,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
} }
}, []); }, []);
useEffect(() => {
if (!fullscreenPreview) return;
const handleEsc = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setFullscreenPreview(null);
}
};
window.addEventListener("keydown", handleEsc);
return () => window.removeEventListener("keydown", handleEsc);
}, [fullscreenPreview]);
// Canvas store // Canvas store
const addNode = useCanvasStore((state) => state.addNode); const addNode = useCanvasStore((state) => state.addNode);
const nodes = useCanvasStore((state) => state.nodes); const nodes = useCanvasStore((state) => state.nodes);
@ -1419,9 +1434,14 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<img <img
src={url} src={url}
alt="" alt=""
className="max-w-full h-auto cursor-pointer hover:scale-105 transition-transform duration-300" className="max-w-full h-auto cursor-zoom-in hover:scale-105 transition-transform duration-300"
style={{ maxHeight: '200px' }} style={{ maxHeight: '200px' }}
onClick={() => window.open(url, '_blank')} onClick={() => setFullscreenPreview({ url })}
onDoubleClick={(e) => {
e.preventDefault();
e.stopPropagation();
setFullscreenPreview({ url });
}}
/> />
</div> </div>
))} ))}
@ -1466,6 +1486,35 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
</ScrollArea> </ScrollArea>
{fullscreenPreview && createPortal(
<div
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center cursor-zoom-out"
onClick={() => setFullscreenPreview(null)}
>
<button
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
onClick={() => setFullscreenPreview(null)}
aria-label="关闭预览"
>
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<img
src={fullscreenPreview.url}
alt={fullscreenPreview.alt || "图片预览"}
className="max-w-[95vw] max-h-[95vh] object-contain"
onClick={(e) => e.stopPropagation()}
/>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
点击空白处或按 Esc 关闭
</div>
</div>,
document.body
)}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */} {/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
<div className="p-4 bg-background/80 backdrop-blur-md border-t"> <div className="p-4 bg-background/80 backdrop-blur-md border-t">

View File

@ -77,6 +77,21 @@ export function CanvasWorkspace() {
return () => window.removeEventListener('resize', updateSize); return () => window.removeEventListener('resize', updateSize);
}, []); }, []);
// macOS 触控板横向双指滑动会触发浏览器前进/后退,必须使用非 passive 监听阻止默认行为
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const preventTrackpadHistorySwipe = (event: WheelEvent) => {
event.preventDefault();
};
container.addEventListener("wheel", preventTrackpadHistorySwipe, { passive: false });
return () => {
container.removeEventListener("wheel", preventTrackpadHistorySwipe);
};
}, []);
const isFileDrag = useCallback((dataTransfer?: DataTransfer | null) => { const isFileDrag = useCallback((dataTransfer?: DataTransfer | null) => {
if (!dataTransfer) return false; if (!dataTransfer) return false;
const types = Array.from(dataTransfer.types || []); const types = Array.from(dataTransfer.types || []);
@ -599,7 +614,7 @@ export function CanvasWorkspace() {
<div <div
ref={containerRef} ref={containerRef}
className={cn( className={cn(
"relative w-full h-full overflow-hidden bg-background", "relative w-full h-full overflow-hidden overscroll-none touch-none bg-background",
tool === 'pan' && "cursor-grab", tool === 'pan' && "cursor-grab",
isPanning && "cursor-grabbing" isPanning && "cursor-grabbing"
)} )}