🎉 first commit
This commit is contained in:
301
app/components/webbuilder/ScreenshotSelector.tsx
Normal file
301
app/components/webbuilder/ScreenshotSelector.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user