ed-mcconnell commited on
Commit
8b7e18e
·
1 Parent(s): 1890c4e

Initial commit for screen cap feature

Browse files
app/components/chat/BaseChat.tsx CHANGED
@@ -25,6 +25,7 @@ import { ExamplePrompts } from '~/components/chat/ExamplePrompts';
25
  import FilePreview from './FilePreview';
26
  import { ModelSelector } from '~/components/chat/ModelSelector';
27
  import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
 
28
 
29
  const TEXTAREA_MIN_HEIGHT = 76;
30
 
@@ -376,6 +377,16 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
376
  setImageDataList?.(imageDataList.filter((_, i) => i !== index));
377
  }}
378
  />
 
 
 
 
 
 
 
 
 
 
379
  <div
380
  className={classNames(
381
  'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
 
25
  import FilePreview from './FilePreview';
26
  import { ModelSelector } from '~/components/chat/ModelSelector';
27
  import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
28
+ import { ScreenshotStateManager } from './ScreenshotStateManager';
29
 
30
  const TEXTAREA_MIN_HEIGHT = 76;
31
 
 
377
  setImageDataList?.(imageDataList.filter((_, i) => i !== index));
378
  }}
379
  />
380
+ <ClientOnly>
381
+ {() => (
382
+ <ScreenshotStateManager
383
+ setUploadedFiles={setUploadedFiles}
384
+ setImageDataList={setImageDataList}
385
+ uploadedFiles={uploadedFiles}
386
+ imageDataList={imageDataList}
387
+ />
388
+ )}
389
+ </ClientOnly>
390
  <div
391
  className={classNames(
392
  'relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg',
app/components/chat/ScreenshotStateManager.tsx ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from 'react';
2
+
3
+ interface ScreenshotStateManagerProps {
4
+ setUploadedFiles?: (files: File[]) => void;
5
+ setImageDataList?: (dataList: string[]) => void;
6
+ uploadedFiles: File[];
7
+ imageDataList: string[];
8
+ }
9
+
10
+ export const ScreenshotStateManager = ({
11
+ setUploadedFiles,
12
+ setImageDataList,
13
+ uploadedFiles,
14
+ imageDataList,
15
+ }: ScreenshotStateManagerProps) => {
16
+ useEffect(() => {
17
+ if (setUploadedFiles && setImageDataList) {
18
+ (window as any).__BOLT_SET_UPLOADED_FILES__ = setUploadedFiles;
19
+ (window as any).__BOLT_SET_IMAGE_DATA_LIST__ = setImageDataList;
20
+ (window as any).__BOLT_UPLOADED_FILES__ = uploadedFiles;
21
+ (window as any).__BOLT_IMAGE_DATA_LIST__ = imageDataList;
22
+ }
23
+
24
+ return () => {
25
+ delete (window as any).__BOLT_SET_UPLOADED_FILES__;
26
+ delete (window as any).__BOLT_SET_IMAGE_DATA_LIST__;
27
+ delete (window as any).__BOLT_UPLOADED_FILES__;
28
+ delete (window as any).__BOLT_IMAGE_DATA_LIST__;
29
+ };
30
+ }, [setUploadedFiles, setImageDataList, uploadedFiles, imageDataList]);
31
+
32
+ return null;
33
+ };
app/components/workbench/Preview.tsx CHANGED
@@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react';
3
  import { IconButton } from '~/components/ui/IconButton';
4
  import { workbenchStore } from '~/lib/stores/workbench';
5
  import { PortDropdown } from './PortDropdown';
 
6
 
7
  export const Preview = memo(() => {
8
  const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -15,6 +16,7 @@ export const Preview = memo(() => {
15
 
16
  const [url, setUrl] = useState('');
17
  const [iframeUrl, setIframeUrl] = useState<string | undefined>();
 
18
 
19
  useEffect(() => {
20
  if (!activePreview) {
@@ -25,7 +27,7 @@ export const Preview = memo(() => {
25
  }
26
 
27
  const { baseUrl } = activePreview;
28
-
29
  setUrl(baseUrl);
30
  setIframeUrl(baseUrl);
31
  }, [activePreview, iframeUrl]);
@@ -78,18 +80,19 @@ export const Preview = memo(() => {
78
  )}
79
  <div className="bg-bolt-elements-background-depth-2 p-2 flex items-center gap-1.5">
80
  <IconButton icon="i-ph:arrow-clockwise" onClick={reloadPreview} />
 
81
  <div
82
  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
83
  focus-within-border-bolt-elements-borderColorActive focus-within:text-bolt-elements-preview-addressBar-textActive"
84
  >
85
- <input
86
  ref={inputRef}
87
  className="w-full bg-transparent outline-none"
88
  type="text"
89
  value={url}
90
  onChange={(event) => {
91
  setUrl(event.target.value);
92
- }}
93
  onKeyDown={(event) => {
94
  if (event.key === 'Enter' && validateUrl(url)) {
95
  setIframeUrl(url);
@@ -114,7 +117,10 @@ export const Preview = memo(() => {
114
  </div>
115
  <div className="flex-1 border-t border-bolt-elements-borderColor">
116
  {activePreview ? (
117
- <iframe ref={iframeRef} className="border-none w-full h-full bg-white" src={iframeUrl} />
 
 
 
118
  ) : (
119
  <div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
120
  )}
 
3
  import { IconButton } from '~/components/ui/IconButton';
4
  import { workbenchStore } from '~/lib/stores/workbench';
5
  import { PortDropdown } from './PortDropdown';
6
+ import { ScreenshotSelector } from './ScreenshotSelector';
7
 
8
  export const Preview = memo(() => {
9
  const iframeRef = useRef<HTMLIFrameElement>(null);
 
16
 
17
  const [url, setUrl] = useState('');
18
  const [iframeUrl, setIframeUrl] = useState<string | undefined>();
19
+ const [isSelectionMode, setIsSelectionMode] = useState(false);
20
 
21
  useEffect(() => {
22
  if (!activePreview) {
 
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 icon="i-ph:selection" onClick={() => setIsSelectionMode(!isSelectionMode)} className={isSelectionMode ? 'bg-bolt-elements-background-depth-3' : ''} />
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 title='URL'
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);
 
117
  </div>
118
  <div className="flex-1 border-t border-bolt-elements-borderColor">
119
  {activePreview ? (
120
+ <>
121
+ <iframe ref={iframeRef} title="preview" className="border-none w-full h-full bg-white" src={iframeUrl} />
122
+ <ScreenshotSelector isSelectionMode={isSelectionMode} setIsSelectionMode={setIsSelectionMode} containerRef={iframeRef} />
123
+ </>
124
  ) : (
125
  <div className="flex w-full h-full justify-center items-center bg-white">No preview available</div>
126
  )}
app/components/workbench/ScreenshotSelector.tsx ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo, useCallback, useState } from 'react';
2
+ import { toast } from 'react-toastify';
3
+
4
+ interface ScreenshotSelectorProps {
5
+ isSelectionMode: boolean;
6
+ setIsSelectionMode: (mode: boolean) => void;
7
+ containerRef: React.RefObject<HTMLElement>;
8
+ }
9
+
10
+ export const ScreenshotSelector = memo(({ isSelectionMode, setIsSelectionMode, containerRef }: ScreenshotSelectorProps) => {
11
+ const [isCapturing, setIsCapturing] = useState(false);
12
+ const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null);
13
+ const [selectionEnd, setSelectionEnd] = useState<{ x: number; y: number } | null>(null);
14
+
15
+ const handleCopySelection = useCallback(async () => {
16
+ if (!isSelectionMode || !selectionStart || !selectionEnd || !containerRef.current) return;
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
+ // Capture the entire screen
30
+ const stream = await navigator.mediaDevices.getDisplayMedia({
31
+ audio: false,
32
+ video: {
33
+ displaySurface: "window"
34
+ }
35
+ } as MediaStreamConstraints);
36
+
37
+ // Set up video with the stream
38
+ video.srcObject = stream;
39
+ await video.play();
40
+
41
+ // Wait for video to be ready
42
+ await new Promise(resolve => setTimeout(resolve, 300));
43
+
44
+ // Create temporary canvas for full screenshot
45
+ const tempCanvas = document.createElement('canvas');
46
+ tempCanvas.width = video.videoWidth;
47
+ tempCanvas.height = video.videoHeight;
48
+ const tempCtx = tempCanvas.getContext('2d');
49
+
50
+ if (!tempCtx) {
51
+ throw new Error('Failed to get temporary canvas context');
52
+ }
53
+
54
+ // Draw the full video frame
55
+ tempCtx.drawImage(video, 0, 0);
56
+
57
+ // Get the container's position in the page
58
+ const containerRect = containerRef.current.getBoundingClientRect();
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
+ // Draw the cropped area
81
+ ctx.drawImage(
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
+ // Clean up video element
133
+ document.body.removeChild(video);
134
+ }
135
+ } catch (error) {
136
+ console.error('Failed to capture screenshot:', error);
137
+ toast.error('Failed to capture screenshot');
138
+ } finally {
139
+ setIsCapturing(false);
140
+ setSelectionStart(null);
141
+ setSelectionEnd(null);
142
+ setIsSelectionMode(false);
143
+ }
144
+ }, [isSelectionMode, selectionStart, selectionEnd, containerRef, setIsSelectionMode]);
145
+
146
+ const handleSelectionStart = useCallback((e: React.MouseEvent) => {
147
+ e.preventDefault();
148
+ e.stopPropagation();
149
+ if (!isSelectionMode) return;
150
+ const rect = e.currentTarget.getBoundingClientRect();
151
+ const x = e.clientX - rect.left;
152
+ const y = e.clientY - rect.top;
153
+ setSelectionStart({ x, y });
154
+ setSelectionEnd({ x, y });
155
+ }, [isSelectionMode]);
156
+
157
+ const handleSelectionMove = useCallback((e: React.MouseEvent) => {
158
+ e.preventDefault();
159
+ e.stopPropagation();
160
+ if (!isSelectionMode || !selectionStart) return;
161
+ const rect = e.currentTarget.getBoundingClientRect();
162
+ const x = e.clientX - rect.left;
163
+ const y = e.clientY - rect.top;
164
+ setSelectionEnd({ x, y });
165
+ }, [isSelectionMode, selectionStart]);
166
+
167
+ if (!isSelectionMode) return null;
168
+
169
+ return (
170
+ <div
171
+ className="absolute inset-0 cursor-crosshair"
172
+ onMouseDown={handleSelectionStart}
173
+ onMouseMove={handleSelectionMove}
174
+ onMouseUp={handleCopySelection}
175
+ onMouseLeave={() => {
176
+ if (selectionStart) {
177
+ setSelectionStart(null);
178
+ }
179
+ }}
180
+ style={{
181
+ backgroundColor: isCapturing ? 'transparent' : 'rgba(0, 0, 0, 0.1)',
182
+ userSelect: 'none',
183
+ WebkitUserSelect: 'none',
184
+ pointerEvents: 'all',
185
+ opacity: isCapturing ? 0 : 1,
186
+ zIndex: 50,
187
+ transition: 'opacity 0.1s ease-in-out',
188
+ }}
189
+ >
190
+ {selectionStart && selectionEnd && !isCapturing && (
191
+ <div
192
+ className="absolute border-2 border-blue-500 bg-blue-200 bg-opacity-20"
193
+ style={{
194
+ left: Math.min(selectionStart.x, selectionEnd.x),
195
+ top: Math.min(selectionStart.y, selectionEnd.y),
196
+ width: Math.abs(selectionEnd.x - selectionStart.x),
197
+ height: Math.abs(selectionEnd.y - selectionStart.y),
198
+ }}
199
+ />
200
+ )}
201
+ </div>
202
+ );
203
+ });