修复画布预览放大交互与触控板横向滑动翻页
This commit is contained in:
parent
be1ad6be1d
commit
1556826c1b
@ -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 += ``;
|
uploadedUrls.push(uploadedUrl);
|
||||||
} catch (error) {
|
rebuilt += ``;
|
||||||
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) => ``)
|
||||||
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizedText += text.slice(cursor);
|
|
||||||
return normalizedText;
|
return normalizedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}`}>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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"
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user