Commit
·
7fdab0a
1
Parent(s):
8b7e18e
Second commit for screen cap feature
Browse files
app/components/workbench/Preview.tsx
CHANGED
@@ -27,7 +27,7 @@ export const Preview = memo(() => {
|
|
27 |
}
|
28 |
|
29 |
const { baseUrl } = activePreview;
|
30 |
-
|
31 |
setUrl(baseUrl);
|
32 |
setIframeUrl(baseUrl);
|
33 |
}, [activePreview, iframeUrl]);
|
@@ -80,19 +80,24 @@ export const Preview = memo(() => {
|
|
80 |
)}
|
81 |
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
|
82 |
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
83 |
-
<IconButton
|
|
|
|
|
|
|
|
|
84 |
<div
|
85 |
className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
|
86 |
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
|
87 |
>
|
88 |
-
<input
|
|
|
89 |
ref={inputRef}
|
90 |
className="w-full bg-transparent outline-none"
|
91 |
type="text"
|
92 |
value={url}
|
93 |
onChange={(event) => {
|
94 |
setUrl(event.target.value);
|
95 |
-
}}
|
96 |
onKeyDown={(event) => {
|
97 |
if (event.key === 'Enter' && validateUrl(url)) {
|
98 |
setIframeUrl(url);
|
@@ -119,7 +124,11 @@ export const Preview = memo(() => {
|
|
119 |
{activePreview ? (
|
120 |
<>
|
121 |
<iframe ref={iframeRef} title="preview" className="border-none w-full h-full bg-white" src={iframeUrl} />
|
122 |
-
<ScreenshotSelector
|
|
|
|
|
|
|
|
|
123 |
</>
|
124 |
) : (
|
125 |
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
|
|
27 |
}
|
28 |
|
29 |
const { baseUrl } = activePreview;
|
30 |
+
|
31 |
setUrl(baseUrl);
|
32 |
setIframeUrl(baseUrl);
|
33 |
}, [activePreview, iframeUrl]);
|
|
|
80 |
)}
|
81 |
<div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
|
82 |
<IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
|
83 |
+
<IconButton
|
84 |
+
icon="i-ph:selection"
|
85 |
+
onClick={() => setIsSelectionMode(!isSelectionMode)}
|
86 |
+
className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''}
|
87 |
+
/>
|
88 |
<div
|
89 |
className="flex items-center gap-1 flex-grow bg-bolt-elements-preview-addressBar-background border border-bolt-elements-borderColor text-bolt-elements-preview-addressBar-text rounded-full px-3 py-1 text-sm hover:bg-bolt-elements-preview-addressBar-backgroundHover hover:focus-within:bg-bolt-elements-preview-addressBar-backgroundActive focus-within:bg-bolt-elements-preview-addressBar-backgroundActive
|
90 |
focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
|
91 |
>
|
92 |
+
<input
|
93 |
+
title="URL"
|
94 |
ref={inputRef}
|
95 |
className="w-full bg-transparent outline-none"
|
96 |
type="text"
|
97 |
value={url}
|
98 |
onChange={(event) => {
|
99 |
setUrl(event.target.value);
|
100 |
+
}}
|
101 |
onKeyDown={(event) => {
|
102 |
if (event.key === 'Enter' && validateUrl(url)) {
|
103 |
setIframeUrl(url);
|
|
|
124 |
{activePreview ? (
|
125 |
<>
|
126 |
<iframe ref={iframeRef} title="preview" className="border-none w-full h-full bg-white" src={iframeUrl} />
|
127 |
+
<ScreenshotSelector
|
128 |
+
isSelectionMode={isSelectionMode}
|
129 |
+
setIsSelectionMode={setIsSelectionMode}
|
130 |
+
containerRef={iframeRef}
|
131 |
+
/>
|
132 |
</>
|
133 |
) : (
|
134 |
<div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
|
app/components/workbench/ScreenshotSelector.tsx
CHANGED
@@ -7,197 +7,194 @@ interface ScreenshotSelectorProps {
|
|
7 |
containerRef: React.RefObject<HTMLElement>;
|
8 |
}
|
9 |
|
10 |
-
export const ScreenshotSelector = memo(
|
11 |
-
|
12 |
-
|
13 |
-
|
|
|
14 |
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
setIsCapturing(true);
|
19 |
-
try {
|
20 |
-
const width = Math.abs(selectionEnd.x - selectionStart.x);
|
21 |
-
const height = Math.abs(selectionEnd.y - selectionStart.y);
|
22 |
-
|
23 |
-
// Create a video element to capture the screen
|
24 |
-
const video = document.createElement('video');
|
25 |
-
video.style.opacity = '0';
|
26 |
-
document.body.appendChild(video);
|
27 |
|
|
|
28 |
try {
|
29 |
-
|
30 |
-
const
|
31 |
-
|
32 |
-
|
33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
34 |
}
|
35 |
-
} as MediaStreamConstraints);
|
36 |
|
37 |
-
|
38 |
-
|
39 |
-
await video.play();
|
40 |
|
41 |
-
|
42 |
-
|
43 |
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
tempCanvas.height = video.videoHeight;
|
48 |
-
const tempCtx = tempCanvas.getContext('2d');
|
49 |
|
50 |
-
|
51 |
-
|
52 |
-
|
|
|
|
|
53 |
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
// Calculate scale factor between video and screen
|
61 |
-
const scaleX = video.videoWidth / window.innerWidth;
|
62 |
-
const scaleY = video.videoHeight / window.innerHeight;
|
63 |
-
|
64 |
-
// Calculate the scaled coordinates
|
65 |
-
const scaledX = (containerRect.left + Math.min(selectionStart.x, selectionEnd.x)) * scaleX;
|
66 |
-
const scaledY = (containerRect.top + Math.min(selectionStart.y, selectionEnd.y)) * scaleY;
|
67 |
-
const scaledWidth = width * scaleX;
|
68 |
-
const scaledHeight = height * scaleY;
|
69 |
-
|
70 |
-
// Create final canvas for the cropped area
|
71 |
-
const canvas = document.createElement('canvas');
|
72 |
-
canvas.width = width;
|
73 |
-
canvas.height = height;
|
74 |
-
const ctx = canvas.getContext('2d');
|
75 |
-
|
76 |
-
if (!ctx) {
|
77 |
-
throw new Error('Failed to get canvas context');
|
78 |
-
}
|
79 |
|
80 |
-
|
81 |
-
|
82 |
-
tempCanvas,
|
83 |
-
scaledX,
|
84 |
-
scaledY,
|
85 |
-
scaledWidth,
|
86 |
-
scaledHeight,
|
87 |
-
0,
|
88 |
-
0,
|
89 |
-
width,
|
90 |
-
height
|
91 |
-
);
|
92 |
-
|
93 |
-
// Convert to blob
|
94 |
-
const blob = await new Promise<Blob>((resolve, reject) => {
|
95 |
-
canvas.toBlob((blob) => {
|
96 |
-
if (blob) resolve(blob);
|
97 |
-
else reject(new Error('Failed to create blob'));
|
98 |
-
}, 'image/png');
|
99 |
-
});
|
100 |
-
|
101 |
-
// Create a FileReader to convert blob to base64
|
102 |
-
const reader = new FileReader();
|
103 |
-
reader.onload = (e) => {
|
104 |
-
const base64Image = e.target?.result as string;
|
105 |
-
|
106 |
-
// Find the textarea element
|
107 |
-
const textarea = document.querySelector('textarea');
|
108 |
-
if (textarea) {
|
109 |
-
// Get the setters from the BaseChat component
|
110 |
-
const setUploadedFiles = (window as any).__BOLT_SET_UPLOADED_FILES__;
|
111 |
-
const setImageDataList = (window as any).__BOLT_SET_IMAGE_DATA_LIST__;
|
112 |
-
const uploadedFiles = (window as any).__BOLT_UPLOADED_FILES__ || [];
|
113 |
-
const imageDataList = (window as any).__BOLT_IMAGE_DATA_LIST__ || [];
|
114 |
-
|
115 |
-
if (setUploadedFiles && setImageDataList) {
|
116 |
-
// Update the files and image data
|
117 |
-
const file = new File([blob], 'screenshot.png', { type: 'image/png' });
|
118 |
-
setUploadedFiles([...uploadedFiles, file]);
|
119 |
-
setImageDataList([...imageDataList, base64Image]);
|
120 |
-
toast.success('Screenshot captured and added to chat');
|
121 |
-
} else {
|
122 |
-
toast.error('Could not add screenshot to chat');
|
123 |
-
}
|
124 |
}
|
125 |
-
};
|
126 |
-
reader.readAsDataURL(blob);
|
127 |
-
|
128 |
-
// Stop all tracks
|
129 |
-
stream.getTracks().forEach(track => track.stop());
|
130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
131 |
} finally {
|
132 |
-
|
133 |
-
|
|
|
|
|
134 |
}
|
135 |
-
}
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
const
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
);
|
203 |
-
});
|
|
|
7 |
containerRef: React.RefObject<HTMLElement>;
|
8 |
}
|
9 |
|
10 |
+
export const ScreenshotSelector = memo(
|
11 |
+
({ isSelectionMode, setIsSelectionMode, containerRef }: ScreenshotSelectorProps) => {
|
12 |
+
const [isCapturing, setIsCapturing] = useState(false);
|
13 |
+
const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
|
14 |
+
const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
|
15 |
|
16 |
+
const handleCopySelection = useCallback(async () => {
|
17 |
+
if (!isSelectionMode || !selectionStart || !selectionEnd || !containerRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
18 |
|
19 |
+
setIsCapturing(true);
|
20 |
try {
|
21 |
+
const width = Math.abs(selectionEnd.x - selectionStart.x);
|
22 |
+
const height = Math.abs(selectionEnd.y - selectionStart.y);
|
23 |
+
|
24 |
+
// Create a video element to capture the screen
|
25 |
+
const video = document.createElement('video');
|
26 |
+
video.style.opacity = '0';
|
27 |
+
document.body.appendChild(video);
|
28 |
+
|
29 |
+
try {
|
30 |
+
// Capture the entire screen
|
31 |
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
32 |
+
audio: false,
|
33 |
+
video: {
|
34 |
+
displaySurface: 'window',
|
35 |
+
},
|
36 |
+
} as MediaStreamConstraints);
|
37 |
+
|
38 |
+
// Set up video with the stream
|
39 |
+
video.srcObject = stream;
|
40 |
+
await video.play();
|
41 |
+
|
42 |
+
// Wait for video to be ready
|
43 |
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
44 |
+
|
45 |
+
// Create temporary canvas for full screenshot
|
46 |
+
const tempCanvas = document.createElement('canvas');
|
47 |
+
tempCanvas.width = video.videoWidth;
|
48 |
+
tempCanvas.height = video.videoHeight;
|
49 |
+
const tempCtx = tempCanvas.getContext('2d');
|
50 |
+
|
51 |
+
if (!tempCtx) {
|
52 |
+
throw new Error('Failed to get temporary canvas context');
|
53 |
}
|
|
|
54 |
|
55 |
+
// Draw the full video frame
|
56 |
+
tempCtx.drawImage(video, 0, 0);
|
|
|
57 |
|
58 |
+
// Get the container's position in the page
|
59 |
+
const containerRect = containerRef.current.getBoundingClientRect();
|
60 |
|
61 |
+
// Calculate scale factor between video and screen
|
62 |
+
const scaleX = video.videoWidth / window.innerWidth;
|
63 |
+
const scaleY = video.videoHeight / window.innerHeight;
|
|
|
|
|
64 |
|
65 |
+
// Calculate the scaled coordinates
|
66 |
+
const scaledX = (containerRect.left + Math.min(selectionStart.x, selectionEnd.x)) * scaleX;
|
67 |
+
const scaledY = (containerRect.top + Math.min(selectionStart.y, selectionEnd.y)) * scaleY;
|
68 |
+
const scaledWidth = width * scaleX;
|
69 |
+
const scaledHeight = height * scaleY;
|
70 |
|
71 |
+
// Create final canvas for the cropped area
|
72 |
+
const canvas = document.createElement('canvas');
|
73 |
+
canvas.width = width;
|
74 |
+
canvas.height = height;
|
75 |
+
const ctx = canvas.getContext('2d');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
76 |
|
77 |
+
if (!ctx) {
|
78 |
+
throw new Error('Failed to get canvas context');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
79 |
}
|
|
|
|
|
|
|
|
|
|
|
80 |
|
81 |
+
// Draw the cropped area
|
82 |
+
ctx.drawImage(tempCanvas, scaledX, scaledY, scaledWidth, scaledHeight, 0, 0, width, height);
|
83 |
+
|
84 |
+
// Convert to blob
|
85 |
+
const blob = await new Promise<Blob>((resolve, reject) => {
|
86 |
+
canvas.toBlob((blob) => {
|
87 |
+
if (blob) resolve(blob);
|
88 |
+
else reject(new Error('Failed to create blob'));
|
89 |
+
}, 'image/png');
|
90 |
+
});
|
91 |
+
|
92 |
+
// Create a FileReader to convert blob to base64
|
93 |
+
const reader = new FileReader();
|
94 |
+
reader.onload = (e) => {
|
95 |
+
const base64Image = e.target?.result as string;
|
96 |
+
|
97 |
+
// Find the textarea element
|
98 |
+
const textarea = document.querySelector('textarea');
|
99 |
+
if (textarea) {
|
100 |
+
// Get the setters from the BaseChat component
|
101 |
+
const setUploadedFiles = (window as any).__BOLT_SET_UPLOADED_FILES__;
|
102 |
+
const setImageDataList = (window as any).__BOLT_SET_IMAGE_DATA_LIST__;
|
103 |
+
const uploadedFiles = (window as any).__BOLT_UPLOADED_FILES__ || [];
|
104 |
+
const imageDataList = (window as any).__BOLT_IMAGE_DATA_LIST__ || [];
|
105 |
+
|
106 |
+
if (setUploadedFiles && setImageDataList) {
|
107 |
+
// Update the files and image data
|
108 |
+
const file = new File([blob], 'screenshot.png', { type: 'image/png' });
|
109 |
+
setUploadedFiles([...uploadedFiles, file]);
|
110 |
+
setImageDataList([...imageDataList, base64Image]);
|
111 |
+
toast.success('Screenshot captured and added to chat');
|
112 |
+
} else {
|
113 |
+
toast.error('Could not add screenshot to chat');
|
114 |
+
}
|
115 |
+
}
|
116 |
+
};
|
117 |
+
reader.readAsDataURL(blob);
|
118 |
+
|
119 |
+
// Stop all tracks
|
120 |
+
stream.getTracks().forEach((track) => track.stop());
|
121 |
+
} finally {
|
122 |
+
// Clean up video element
|
123 |
+
document.body.removeChild(video);
|
124 |
+
}
|
125 |
+
} catch (error) {
|
126 |
+
console.error('Failed to capture screenshot:', error);
|
127 |
+
toast.error('Failed to capture screenshot');
|
128 |
} finally {
|
129 |
+
setIsCapturing(false);
|
130 |
+
setSelectionStart(null);
|
131 |
+
setSelectionEnd(null);
|
132 |
+
setIsSelectionMode(false);
|
133 |
}
|
134 |
+
}, [isSelectionMode, selectionStart, selectionEnd, containerRef, setIsSelectionMode]);
|
135 |
+
|
136 |
+
const handleSelectionStart = useCallback(
|
137 |
+
(e: React.MouseEvent) => {
|
138 |
+
e.preventDefault();
|
139 |
+
e.stopPropagation();
|
140 |
+
if (!isSelectionMode) return;
|
141 |
+
const rect = e.currentTarget.getBoundingClientRect();
|
142 |
+
const x = e.clientX - rect.left;
|
143 |
+
const y = e.clientY - rect.top;
|
144 |
+
setSelectionStart({ x, y });
|
145 |
+
setSelectionEnd({ x, y });
|
146 |
+
},
|
147 |
+
[isSelectionMode],
|
148 |
+
);
|
149 |
+
|
150 |
+
const handleSelectionMove = useCallback(
|
151 |
+
(e: React.MouseEvent) => {
|
152 |
+
e.preventDefault();
|
153 |
+
e.stopPropagation();
|
154 |
+
if (!isSelectionMode || !selectionStart) return;
|
155 |
+
const rect = e.currentTarget.getBoundingClientRect();
|
156 |
+
const x = e.clientX - rect.left;
|
157 |
+
const y = e.clientY - rect.top;
|
158 |
+
setSelectionEnd({ x, y });
|
159 |
+
},
|
160 |
+
[isSelectionMode, selectionStart],
|
161 |
+
);
|
162 |
+
|
163 |
+
if (!isSelectionMode) return null;
|
164 |
+
|
165 |
+
return (
|
166 |
+
<div
|
167 |
+
className="absolute inset-0 cursor-crosshair"
|
168 |
+
onMouseDown={handleSelectionStart}
|
169 |
+
onMouseMove={handleSelectionMove}
|
170 |
+
onMouseUp={handleCopySelection}
|
171 |
+
onMouseLeave={() => {
|
172 |
+
if (selectionStart) {
|
173 |
+
setSelectionStart(null);
|
174 |
+
}
|
175 |
+
}}
|
176 |
+
style={{
|
177 |
+
backgroundColor: isCapturing ? 'transparent' : 'rgba(0, 0, 0, 0.1)',
|
178 |
+
userSelect: 'none',
|
179 |
+
WebkitUserSelect: 'none',
|
180 |
+
pointerEvents: 'all',
|
181 |
+
opacity: isCapturing ? 0 : 1,
|
182 |
+
zIndex: 50,
|
183 |
+
transition: 'opacity 0.1s ease-in-out',
|
184 |
+
}}
|
185 |
+
>
|
186 |
+
{selectionStart && selectionEnd && !isCapturing && (
|
187 |
+
<div
|
188 |
+
className="absolute border-2 border-blue-500 bg-blue-200 bg-opacity-20"
|
189 |
+
style={{
|
190 |
+
left: Math.min(selectionStart.x, selectionEnd.x),
|
191 |
+
top: Math.min(selectionStart.y, selectionEnd.y),
|
192 |
+
width: Math.abs(selectionEnd.x - selectionStart.x),
|
193 |
+
height: Math.abs(selectionEnd.y - selectionStart.y),
|
194 |
+
}}
|
195 |
+
/>
|
196 |
+
)}
|
197 |
+
</div>
|
198 |
+
);
|
199 |
+
},
|
200 |
+
);
|
|
|
|