fix: improve canvas drag drop and placeholder timing
Harden desktop drag-and-drop on the canvas and ensure auto image placeholders wait for visible assistant text.
This commit is contained in:
parent
0cc791a71e
commit
2e6f421b6d
@ -41,6 +41,7 @@ export function CanvasWorkspace() {
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
||||
const [containerSize, setContainerSize] = useState({ width: 1920, height: 1080 });
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
// Store state
|
||||
const nodes = useCanvasStore(canvasSelectors.nodes);
|
||||
@ -64,6 +65,43 @@ export function CanvasWorkspace() {
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}, []);
|
||||
|
||||
const isFileDrag = useCallback((dataTransfer?: DataTransfer | null) => {
|
||||
if (!dataTransfer) return false;
|
||||
const types = Array.from(dataTransfer.types || []);
|
||||
if (
|
||||
types.includes("Files") ||
|
||||
types.includes("application/x-moz-file") ||
|
||||
types.includes("public.file-url")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const items = dataTransfer.items;
|
||||
if (items && Array.from(items).some((item) => item.kind === "file")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// 防止浏览器默认打开文件
|
||||
React.useEffect(() => {
|
||||
const handleWindowDragOver = (e: DragEvent) => {
|
||||
if (isFileDrag(e.dataTransfer)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
const handleWindowDrop = (e: DragEvent) => {
|
||||
if (isFileDrag(e.dataTransfer)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener("dragover", handleWindowDragOver);
|
||||
window.addEventListener("drop", handleWindowDrop);
|
||||
return () => {
|
||||
window.removeEventListener("dragover", handleWindowDragOver);
|
||||
window.removeEventListener("drop", handleWindowDrop);
|
||||
};
|
||||
}, [isFileDrag]);
|
||||
|
||||
// 性能优化:只渲染视口内的节点(虚拟化)
|
||||
const visibleNodes = useMemo(() => {
|
||||
@ -212,16 +250,29 @@ export function CanvasWorkspace() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDrag(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
dragCounterRef.current += 1;
|
||||
}, [isFileDrag]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDrag(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
|
||||
}, [isFileDrag]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (e.dataTransfer?.types?.includes?.("Files")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}, []);
|
||||
if (!isFileDrag(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}, [isFileDrag]);
|
||||
|
||||
const handleDrop = useCallback(async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDrag(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = 0;
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter((file) => file.type.startsWith("image/"));
|
||||
if (files.length === 0) return;
|
||||
|
||||
@ -282,7 +333,7 @@ export function CanvasWorkspace() {
|
||||
toast.error("图片上传失败");
|
||||
}
|
||||
}
|
||||
}, [addNode, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
|
||||
}, [addNode, getImageDimensionsFromFile, isFileDrag, updateNode, viewport.x, viewport.y, viewport.zoom]);
|
||||
|
||||
// 渲染节点
|
||||
const renderNode = (node: CanvasNode) => {
|
||||
|
||||
@ -133,6 +133,17 @@ function PureMessages({
|
||||
|
||||
// 控制等待动画延迟消失(Gemini 图片模型延迟 4 秒,其他模型 6 秒)
|
||||
const [showThinkingWithDelay, setShowThinkingWithDelay] = useState(false);
|
||||
const [autoImagePlaceholderReady, setAutoImagePlaceholderReady] = useState(false);
|
||||
const autoImagePlaceholderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoImagePlaceholderTimerRef.current) {
|
||||
clearTimeout(autoImagePlaceholderTimerRef.current);
|
||||
autoImagePlaceholderTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
|
||||
@ -299,6 +310,53 @@ function PureMessages({
|
||||
}
|
||||
}, [status, messages, isGeminiModel, isQianwenVideoModel, isVolcanoVisionModel, isVideoModel, isAlibabaImageModel, selectedModelId, isGeneratingImageFlag]);
|
||||
|
||||
// 🆕 智能自动模式:确保文字可见后再显示生图占位图
|
||||
useEffect(() => {
|
||||
const lastUserMessage = [...messages].reverse().find(m => m.role === "user");
|
||||
const isAutoModeWithImage = selectedModelId === "auto" &&
|
||||
lastUserMessage?.parts?.some((p: any) => p.type === "file" || p.type === "image");
|
||||
|
||||
const hasAssistantTextReplyWithContent = messages.some(msg =>
|
||||
msg.role === "assistant" && msg.parts?.some((part: any) => {
|
||||
if (part.type !== "text" || !part.text) return false;
|
||||
if (!part.text.includes("<!-- GENERATING_IMAGE -->")) return false;
|
||||
const cleanedText = part.text.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "").trim();
|
||||
return cleanedText.length > 0;
|
||||
})
|
||||
);
|
||||
|
||||
let hasImageContent = false;
|
||||
if (isAutoModeWithImage) {
|
||||
const lastUserIndex = messages.findLastIndex(m => m.role === "user");
|
||||
const assistantMessagesAfterUser = messages.slice(lastUserIndex + 1).filter(m => m.role === "assistant");
|
||||
hasImageContent = assistantMessagesAfterUser.some(msg =>
|
||||
msg.parts?.some((part: any) => part.type === "text" && part.text && (
|
||||
part.text.includes("![thumb|") ||
|
||||
part.text.includes("![thumb|||") ||
|
||||
part.text.includes("![Generated Image")
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAutoModeWithImage || !isGeneratingImageFlag || !hasAssistantTextReplyWithContent || hasImageContent) {
|
||||
if (autoImagePlaceholderTimerRef.current) {
|
||||
clearTimeout(autoImagePlaceholderTimerRef.current);
|
||||
autoImagePlaceholderTimerRef.current = null;
|
||||
}
|
||||
if (autoImagePlaceholderReady) {
|
||||
setAutoImagePlaceholderReady(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoImagePlaceholderReady && !autoImagePlaceholderTimerRef.current) {
|
||||
autoImagePlaceholderTimerRef.current = setTimeout(() => {
|
||||
setAutoImagePlaceholderReady(true);
|
||||
autoImagePlaceholderTimerRef.current = null;
|
||||
}, 800);
|
||||
}
|
||||
}, [messages, selectedModelId, isGeneratingImageFlag, autoImagePlaceholderReady]);
|
||||
|
||||
// 🆕 监听新消息添加,当用户发送带图片的消息或添加加载消息时,强制滚动到底部
|
||||
const prevMessagesLengthRef = useRef(messages.length);
|
||||
useEffect(() => {
|
||||
@ -490,7 +548,7 @@ function PureMessages({
|
||||
if (isAutoMode && userMessageHasImage) {
|
||||
// 🆕 只用 isGeneratingImageFlag 来控制是否显示生图占位图
|
||||
// isGeneratingImageFlag 由 chat.tsx 控制:延迟 3 秒后设置为 true,收到图片后设置为 false
|
||||
if (isGeneratingImageFlag && hasAssistantTextReplyWithContent) {
|
||||
if (autoImagePlaceholderReady && isGeneratingImageFlag && hasAssistantTextReplyWithContent) {
|
||||
return <ImageGenerationLoading imageCount={imageCount} maxImages={6} providerType="alibaba-image" modelName="智能处理" />;
|
||||
}
|
||||
// 否则显示思考动画(在等待 AI 分析或延迟期间)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user