Files
upage-git/app/components/webbuilder/ScreenshotSelector.tsx
2025-09-24 17:02:44 +08:00

302 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
interface ScreenshotSelectorProps {
isSelectionMode: boolean;
setIsSelectionMode: (mode: boolean) => void;
containerRef: React.RefObject<HTMLElement | null>;
}
export const ScreenshotSelector = memo(
({ isSelectionMode, setIsSelectionMode, containerRef }: ScreenshotSelectorProps) => {
const [isCapturing, setIsCapturing] = useState(false);
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const hasRequestedPermissionRef = useRef(false);
// 清理流和视频资源的函数
const cleanupResources = useCallback(() => {
if (videoRef.current) {
videoRef.current.pause();
videoRef.current.srcObject = null;
videoRef.current.remove();
videoRef.current = null;
}
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
mediaStreamRef.current = null;
}
hasRequestedPermissionRef.current = false;
}, []);
// 当组件卸载时清理资源
useEffect(() => {
return cleanupResources;
}, [cleanupResources]);
// 当选择模式关闭时清理资源
useEffect(() => {
if (!isSelectionMode && mediaStreamRef.current) {
cleanupResources();
}
}, [isSelectionMode, cleanupResources]);
const initializeStream = async () => {
// 如果已经有流,直接返回
if (mediaStreamRef.current) {
return mediaStreamRef.current;
}
// 如果已经请求了权限等待并返回null避免重复请求
if (hasRequestedPermissionRef.current) {
return null;
}
hasRequestedPermissionRef.current = true;
try {
// 请求显示媒体权限
const stream = await navigator.mediaDevices.getDisplayMedia({
audio: false,
video: {
displaySurface: 'window',
preferCurrentTab: true,
surfaceSwitching: 'include',
systemAudio: 'exclude',
},
} as MediaStreamConstraints);
// 添加流失效时的处理器
stream.addEventListener('inactive', () => {
cleanupResources();
setIsSelectionMode(false);
setSelectionStart(null);
setSelectionEnd(null);
setIsCapturing(false);
});
mediaStreamRef.current = stream;
// 初始化视频元素
if (!videoRef.current) {
const video = document.createElement('video');
video.style.opacity = '0';
video.style.position = 'fixed';
video.style.pointerEvents = 'none';
video.style.zIndex = '-1';
document.body.appendChild(video);
videoRef.current = video;
}
// 设置视频流并播放
videoRef.current.srcObject = stream;
await videoRef.current.play();
return stream;
} catch (error) {
console.error('Failed to initialize stream:', error);
setIsSelectionMode(false);
toast.error('初始化屏幕捕获失败');
hasRequestedPermissionRef.current = false;
return null;
}
};
const handleCopySelection = useCallback(async () => {
if (!isSelectionMode || !selectionStart || !selectionEnd || !containerRef.current) {
return;
}
setIsCapturing(true);
try {
const stream = await initializeStream();
if (!stream || !videoRef.current) {
setIsCapturing(false);
return;
}
// 等待视频准备好
await new Promise((resolve) => setTimeout(resolve, 300));
// 创建临时画布进行全屏截图
const tempCanvas = document.createElement('canvas');
tempCanvas.width = videoRef.current.videoWidth;
tempCanvas.height = videoRef.current.videoHeight;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) {
throw new Error('无法获取临时画布上下文');
}
// 绘制完整视频帧
tempCtx.drawImage(videoRef.current, 0, 0);
// 计算视频和屏幕之间的比例因子
const scaleX = videoRef.current.videoWidth / window.innerWidth;
const scaleY = videoRef.current.videoHeight / window.innerHeight;
// 获取窗口滚动位置
const scrollX = window.scrollX;
const scrollY = window.scrollY + 40;
// 获取容器在页面上的位置
const containerRect = containerRef.current.getBoundingClientRect();
// 精确剪裁的偏移调整
const leftOffset = -9; // 调整左侧位置
const bottomOffset = -14; // 调整底部位置
// 计算缩放后的坐标(带滚动偏移和调整)
const scaledX = Math.round(
(containerRect.left + Math.min(selectionStart.x, selectionEnd.x) + scrollX + leftOffset) * scaleX,
);
const scaledY = Math.round(
(containerRect.top + Math.min(selectionStart.y, selectionEnd.y) + scrollY + bottomOffset) * scaleY,
);
const scaledWidth = Math.round(Math.abs(selectionEnd.x - selectionStart.x) * scaleX);
const scaledHeight = Math.round(Math.abs(selectionEnd.y - selectionStart.y) * scaleY);
// 为裁剪区域创建最终画布
const canvas = document.createElement('canvas');
canvas.width = Math.round(Math.abs(selectionEnd.x - selectionStart.x));
canvas.height = Math.round(Math.abs(selectionEnd.y - selectionStart.y));
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法获取画布上下文');
}
// 绘制裁剪区域
ctx.drawImage(tempCanvas, scaledX, scaledY, scaledWidth, scaledHeight, 0, 0, canvas.width, canvas.height);
// 转换为 blob
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('无法创建 blob'));
}
}, 'image/png');
});
// 创建 FileReader 将 blob 转换为 base64
const reader = new FileReader();
reader.onload = () => {
// 查找 textarea 元素
const textarea = document.querySelector('textarea');
if (textarea) {
// 从 BaseChat 组件获取 setters
const setUploadedFiles = (window as any).__UPAGE_SET_UPLOADED_FILES__;
const uploadedFiles = (window as any).__UPAGE_UPLOADED_FILES__ || [];
if (setUploadedFiles) {
// 更新文件和图像数据
const file = new File([blob], 'screenshot.png', { type: 'image/png' });
setUploadedFiles([...uploadedFiles, file]);
toast.success('截图已添加到聊天中');
} else {
toast.error('无法将截图添加到聊天中');
}
}
};
reader.readAsDataURL(blob);
} catch (error) {
console.error('Failed to capture screenshot:', error);
toast.error('截图失败');
cleanupResources();
} finally {
setIsCapturing(false);
setSelectionStart(null);
setSelectionEnd(null);
setIsSelectionMode(false); // 捕获后关闭选择模式
}
}, [isSelectionMode, selectionStart, selectionEnd, containerRef, setIsSelectionMode, cleanupResources]);
const handleSelectionStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isSelectionMode) {
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setSelectionStart({ x, y });
setSelectionEnd({ x, y });
},
[isSelectionMode],
);
const handleSelectionMove = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isSelectionMode || !selectionStart) {
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setSelectionEnd({ x, y });
},
[isSelectionMode, selectionStart],
);
if (!isSelectionMode) {
return null;
}
return (
<div
className="absolute inset-0 cursor-crosshair"
onMouseDown={handleSelectionStart}
onMouseMove={handleSelectionMove}
onMouseUp={handleCopySelection}
onMouseLeave={() => {
if (selectionStart) {
setSelectionStart(null);
}
}}
style={{
backgroundColor: isCapturing ? 'transparent' : 'rgba(0, 0, 0, 0.1)',
userSelect: 'none',
WebkitUserSelect: 'none',
pointerEvents: 'all',
opacity: isCapturing ? 0 : 1,
zIndex: 50,
transition: 'opacity 0.1s ease-in-out',
}}
>
{selectionStart && selectionEnd && !isCapturing && (
<div
className="absolute border-2 border-blue-500 bg-blue-200 bg-opacity-20"
style={{
left: Math.min(selectionStart.x, selectionEnd.x),
top: Math.min(selectionStart.y, selectionEnd.y),
width: Math.abs(selectionEnd.x - selectionStart.x),
height: Math.abs(selectionEnd.y - selectionStart.y),
}}
/>
)}
</div>
);
},
);