blob: 355c9fc1a6b12f8fbba35a566d8ad54232bcec14 [file] [log] [blame]
Sean McCullough86b56862025-04-18 13:04:03 -07001import { css, html, LitElement, PropertyValues } from "lit";
Philip Zeyliger73db6052025-04-23 13:09:07 -07002import { customElement, property, state, query } from "lit/decorators.js";
Sean McCullough86b56862025-04-18 13:04:03 -07003
4@customElement("sketch-chat-input")
5export class SketchChatInput extends LitElement {
Philip Zeyliger73db6052025-04-23 13:09:07 -07006 @state()
Sean McCullough86b56862025-04-18 13:04:03 -07007 content: string = "";
8
Pokey Rule339b56e2025-05-15 14:48:07 +00009 @state()
10 isDraggingOver: boolean = false;
11
Pokey Rule044a62e2025-05-16 10:40:59 +000012 @state()
13 uploadsInProgress: number = 0;
14
15 @state()
16 showUploadInProgressMessage: boolean = false;
17
Sean McCullough86b56862025-04-18 13:04:03 -070018 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
19 // Note that these styles only apply to the scope of this web component's
20 // shadow DOM node, so they won't leak out or collide with CSS declared in
21 // other components or the containing web page (...unless you want it to do that).
22 static styles = css`
23 /* Chat styles - exactly matching timeline.css */
24 .chat-container {
Sean McCullough86b56862025-04-18 13:04:03 -070025 width: 100%;
26 background: #f0f0f0;
27 padding: 15px;
Sean McCullough86b56862025-04-18 13:04:03 -070028 min-height: 40px; /* Ensure minimum height */
Pokey Rule339b56e2025-05-15 14:48:07 +000029 position: relative;
Sean McCullough86b56862025-04-18 13:04:03 -070030 }
31
32 .chat-input-wrapper {
33 display: flex;
34 max-width: 1200px;
35 margin: 0 auto;
36 gap: 10px;
37 }
38
39 #chatInput {
40 flex: 1;
41 padding: 12px;
42 border: 1px solid #ddd;
43 border-radius: 4px;
Sean McCullough07b3e392025-04-21 22:51:14 +000044 resize: vertical;
Sean McCullough86b56862025-04-18 13:04:03 -070045 font-family: monospace;
46 font-size: 12px;
47 min-height: 40px;
Sean McCullough07b3e392025-04-21 22:51:14 +000048 max-height: 300px;
Sean McCullough86b56862025-04-18 13:04:03 -070049 background: #f7f7f7;
Sean McCullough5164eee2025-04-21 18:20:23 -070050 overflow-y: auto;
Sean McCullough07b3e392025-04-21 22:51:14 +000051 box-sizing: border-box; /* Ensure padding is included in height calculation */
52 line-height: 1.4; /* Consistent line height for better height calculation */
Sean McCullough86b56862025-04-18 13:04:03 -070053 }
54
55 #sendChatButton {
56 background-color: #2196f3;
57 color: white;
58 border: none;
59 border-radius: 4px;
60 padding: 0 20px;
61 cursor: pointer;
62 font-weight: 600;
Pokey Rule97188fc2025-04-23 15:50:04 +010063 align-self: center;
64 height: 40px;
Sean McCullough86b56862025-04-18 13:04:03 -070065 }
66
67 #sendChatButton:hover {
68 background-color: #0d8bf2;
69 }
Pokey Rule339b56e2025-05-15 14:48:07 +000070
Pokey Rule044a62e2025-05-16 10:40:59 +000071 #sendChatButton:disabled {
72 background-color: #b0b0b0;
73 cursor: not-allowed;
74 }
75
Pokey Rule339b56e2025-05-15 14:48:07 +000076 /* Drop zone styling */
77 .drop-zone-overlay {
78 position: absolute;
79 top: 0;
80 left: 0;
81 right: 0;
82 bottom: 0;
83 background-color: rgba(33, 150, 243, 0.1);
84 border: 2px dashed #2196f3;
85 border-radius: 4px;
86 display: flex;
87 justify-content: center;
88 align-items: center;
89 z-index: 10;
90 pointer-events: none;
91 }
92
Pokey Rule044a62e2025-05-16 10:40:59 +000093 .drop-zone-message,
94 .upload-progress-message {
Pokey Rule339b56e2025-05-15 14:48:07 +000095 background-color: #ffffff;
96 padding: 15px 20px;
97 border-radius: 4px;
98 font-weight: 600;
99 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
100 }
Pokey Rule044a62e2025-05-16 10:40:59 +0000101
102 .upload-progress-message {
103 position: absolute;
104 bottom: 70px;
105 left: 50%;
106 transform: translateX(-50%);
107 background-color: #fff9c4;
108 border: 1px solid #fbc02d;
109 z-index: 20;
110 font-size: 14px;
111 animation: fadeIn 0.3s ease-in-out;
112 }
113
114 @keyframes fadeIn {
115 from {
116 opacity: 0;
117 transform: translateX(-50%) translateY(10px);
118 }
119 to {
120 opacity: 1;
121 transform: translateX(-50%) translateY(0);
122 }
123 }
Sean McCullough86b56862025-04-18 13:04:03 -0700124 `;
125
126 constructor() {
127 super();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700128 this._handleDiffComment = this._handleDiffComment.bind(this);
Pokey Rule339b56e2025-05-15 14:48:07 +0000129 this._handleDragOver = this._handleDragOver.bind(this);
130 this._handleDragEnter = this._handleDragEnter.bind(this);
131 this._handleDragLeave = this._handleDragLeave.bind(this);
132 this._handleDrop = this._handleDrop.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -0700133 }
134
Sean McCullough86b56862025-04-18 13:04:03 -0700135 connectedCallback() {
136 super.connectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700137 window.addEventListener("diff-comment", this._handleDiffComment);
138 }
139
Pokey Rule339b56e2025-05-15 14:48:07 +0000140 // Utility function to handle file uploads (used by both paste and drop handlers)
141 private async _uploadFile(file: File, insertPosition: number) {
142 // Insert a placeholder at the cursor position
143 const textBefore = this.content.substring(0, insertPosition);
144 const textAfter = this.content.substring(insertPosition);
145
Pokey Rule044a62e2025-05-16 10:40:59 +0000146 // Add a loading indicator with a visual cue
147 const loadingText = `[🔄 Uploading ${file.name}...]`;
Pokey Rule339b56e2025-05-15 14:48:07 +0000148 this.content = `${textBefore}${loadingText}${textAfter}`;
149
Pokey Rule044a62e2025-05-16 10:40:59 +0000150 // Increment uploads in progress counter
151 this.uploadsInProgress++;
152
Pokey Rule339b56e2025-05-15 14:48:07 +0000153 // Adjust spacing immediately to show loading indicator
154 requestAnimationFrame(() => this.adjustChatSpacing());
155
156 try {
157 // Create a FormData object to send the file
158 const formData = new FormData();
159 formData.append("file", file);
160
161 // Upload the file to the server using a relative path
162 const response = await fetch("./upload", {
163 method: "POST",
164 body: formData,
165 });
166
167 if (!response.ok) {
168 throw new Error(`Upload failed: ${response.statusText}`);
169 }
170
171 const data = await response.json();
172
173 // Replace the loading placeholder with the actual file path
174 this.content = this.content.replace(loadingText, `[${data.path}]`);
175
Pokey Rule339b56e2025-05-15 14:48:07 +0000176 return data.path;
177 } catch (error) {
178 console.error("Failed to upload file:", error);
179
180 // Replace loading indicator with error message
181 const errorText = `[Upload failed: ${error.message}]`;
182 this.content = this.content.replace(loadingText, errorText);
183
184 // Adjust spacing to show error message
185 requestAnimationFrame(() => {
186 this.adjustChatSpacing();
187 this.chatInput.focus();
188 });
189
190 throw error;
Pokey Rule044a62e2025-05-16 10:40:59 +0000191 } finally {
192 // Always decrement the counter, even if there was an error
193 this.uploadsInProgress--;
Pokey Rule339b56e2025-05-15 14:48:07 +0000194 }
195 }
196
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000197 // Handle paste events for files (including images)
198 private _handlePaste = async (event: ClipboardEvent) => {
199 // Check if the clipboard contains files
200 if (event.clipboardData && event.clipboardData.files.length > 0) {
201 const file = event.clipboardData.files[0];
202
203 // Handle the file upload (for any file type, not just images)
204 event.preventDefault(); // Prevent default paste behavior
205
Pokey Rule339b56e2025-05-15 14:48:07 +0000206 // Get the current cursor position
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000207 const cursorPos = this.chatInput.selectionStart;
Pokey Rule339b56e2025-05-15 14:48:07 +0000208 await this._uploadFile(file, cursorPos);
209 }
210 };
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000211
Pokey Rule339b56e2025-05-15 14:48:07 +0000212 // Handle drag events for file drop operation
213 private _handleDragOver(event: DragEvent) {
214 event.preventDefault(); // Necessary to allow dropping
215 event.stopPropagation();
216 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000217
Pokey Rule339b56e2025-05-15 14:48:07 +0000218 private _handleDragEnter(event: DragEvent) {
219 event.preventDefault();
220 event.stopPropagation();
221 this.isDraggingOver = true;
222 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000223
Pokey Rule339b56e2025-05-15 14:48:07 +0000224 private _handleDragLeave(event: DragEvent) {
225 event.preventDefault();
226 event.stopPropagation();
227 // Only set to false if we're leaving the container (not entering a child element)
228 if (event.target === this.renderRoot.querySelector(".chat-container")) {
229 this.isDraggingOver = false;
230 }
231 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000232
Pokey Rule339b56e2025-05-15 14:48:07 +0000233 private _handleDrop = async (event: DragEvent) => {
234 event.preventDefault();
235 event.stopPropagation();
236 this.isDraggingOver = false;
237
238 // Check if the dataTransfer contains files
239 if (event.dataTransfer && event.dataTransfer.files.length > 0) {
240 // Process all dropped files
241 for (let i = 0; i < event.dataTransfer.files.length; i++) {
242 const file = event.dataTransfer.files[i];
243 try {
244 // For the first file, insert at the cursor position
245 // For subsequent files, append at the end of the content
246 const insertPosition =
247 i === 0 ? this.chatInput.selectionStart : this.content.length;
248 await this._uploadFile(file, insertPosition);
249
250 // Add a space between multiple files
251 if (i < event.dataTransfer.files.length - 1) {
252 this.content += " ";
253 }
254 } catch (error) {
255 // Error already handled in _uploadFile
256 console.error("Failed to process dropped file:", error);
257 // Continue with the next file
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000258 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000259 }
260 }
261 };
262
Philip Zeyliger73db6052025-04-23 13:09:07 -0700263 private _handleDiffComment(event: CustomEvent) {
264 const { comment } = event.detail;
265 if (!comment) return;
266
267 if (this.content != "") {
268 this.content += "\n\n";
269 }
270 this.content += comment;
271 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700272 }
273
274 // See https://lit.dev/docs/components/lifecycle/
275 disconnectedCallback() {
276 super.disconnectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700277 window.removeEventListener("diff-comment", this._handleDiffComment);
Pokey Rule339b56e2025-05-15 14:48:07 +0000278
279 // Clean up drag and drop event listeners
280 const container = this.renderRoot.querySelector(".chat-container");
281 if (container) {
282 container.removeEventListener("dragover", this._handleDragOver);
283 container.removeEventListener("dragenter", this._handleDragEnter);
284 container.removeEventListener("dragleave", this._handleDragLeave);
285 container.removeEventListener("drop", this._handleDrop);
286 }
287
288 // Clean up paste event listener
289 if (this.chatInput) {
290 this.chatInput.removeEventListener("paste", this._handlePaste);
291 }
Sean McCullough86b56862025-04-18 13:04:03 -0700292 }
293
294 sendChatMessage() {
Pokey Rule044a62e2025-05-16 10:40:59 +0000295 // Prevent sending if there are uploads in progress
296 if (this.uploadsInProgress > 0) {
297 console.log(
298 `Message send prevented: ${this.uploadsInProgress} uploads in progress`,
299 );
Philip Zeyliger73db6052025-04-23 13:09:07 -0700300
Pokey Rule044a62e2025-05-16 10:40:59 +0000301 // Show message to user
302 this.showUploadInProgressMessage = true;
303
304 // Hide the message after 3 seconds
305 setTimeout(() => {
306 this.showUploadInProgressMessage = false;
307 }, 3000);
308
309 return;
310 }
311
312 // Only send if there's actual content (not just whitespace)
313 if (this.content.trim()) {
314 const event = new CustomEvent("send-chat", {
315 detail: { message: this.content },
316 bubbles: true,
317 composed: true,
318 });
319 this.dispatchEvent(event);
320
321 // TODO(philip?): Ideally we only clear the content if the send is successful.
322 this.content = ""; // Clear content after sending
323 }
Sean McCullough86b56862025-04-18 13:04:03 -0700324 }
325
326 adjustChatSpacing() {
Sean McCullough07b3e392025-04-21 22:51:14 +0000327 if (!this.chatInput) return;
Sean McCullough5164eee2025-04-21 18:20:23 -0700328
Sean McCullough07b3e392025-04-21 22:51:14 +0000329 // Reset height to minimal value to correctly calculate scrollHeight
Sean McCullough5164eee2025-04-21 18:20:23 -0700330 this.chatInput.style.height = "auto";
331
Sean McCullough07b3e392025-04-21 22:51:14 +0000332 // Get the scroll height (content height)
333 const scrollHeight = this.chatInput.scrollHeight;
Sean McCullough5164eee2025-04-21 18:20:23 -0700334
Sean McCullough07b3e392025-04-21 22:51:14 +0000335 // Set the height to match content (up to max-height which is handled by CSS)
336 this.chatInput.style.height = `${scrollHeight}px`;
Sean McCullough86b56862025-04-18 13:04:03 -0700337 }
338
Philip Zeyliger73db6052025-04-23 13:09:07 -0700339 async _sendChatClicked() {
Sean McCullough86b56862025-04-18 13:04:03 -0700340 this.sendChatMessage();
341 this.chatInput.focus(); // Refocus the input after sending
Sean McCullough07b3e392025-04-21 22:51:14 +0000342 // Reset height after sending a message
343 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700344 }
345
346 _chatInputKeyDown(event: KeyboardEvent) {
347 // Send message if Enter is pressed without Shift key
348 if (event.key === "Enter" && !event.shiftKey) {
349 event.preventDefault(); // Prevent default newline
350 this.sendChatMessage();
351 }
352 }
353
354 _chatInputChanged(event) {
355 this.content = event.target.value;
Sean McCullough07b3e392025-04-21 22:51:14 +0000356 // Use requestAnimationFrame to ensure DOM updates have completed
Sean McCullough86b56862025-04-18 13:04:03 -0700357 requestAnimationFrame(() => this.adjustChatSpacing());
358 }
359
360 @query("#chatInput")
Sean McCullough07b3e392025-04-21 22:51:14 +0000361 chatInput: HTMLTextAreaElement;
Sean McCullough86b56862025-04-18 13:04:03 -0700362
363 protected firstUpdated(): void {
364 if (this.chatInput) {
365 this.chatInput.focus();
Sean McCullough07b3e392025-04-21 22:51:14 +0000366 // Initialize the input height
367 this.adjustChatSpacing();
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000368
369 // Add paste event listener for image handling
370 this.chatInput.addEventListener("paste", this._handlePaste);
Pokey Rule339b56e2025-05-15 14:48:07 +0000371
372 // Add drag and drop event listeners
373 const container = this.renderRoot.querySelector(".chat-container");
374 if (container) {
375 container.addEventListener("dragover", this._handleDragOver);
376 container.addEventListener("dragenter", this._handleDragEnter);
377 container.addEventListener("dragleave", this._handleDragLeave);
378 container.addEventListener("drop", this._handleDrop);
379 }
Sean McCullough86b56862025-04-18 13:04:03 -0700380 }
Josh Bleecher Snydere2c7f722025-05-01 21:58:41 +0000381
382 // Add window.onload handler to ensure the input is focused when the page fully loads
383 window.addEventListener(
384 "load",
385 () => {
386 if (this.chatInput) {
387 this.chatInput.focus();
388 }
389 },
390 { once: true },
391 );
Sean McCullough86b56862025-04-18 13:04:03 -0700392 }
393
394 render() {
395 return html`
396 <div class="chat-container">
397 <div class="chat-input-wrapper">
398 <textarea
399 id="chatInput"
400 placeholder="Type your message here and press Enter to send..."
401 autofocus
402 @keydown="${this._chatInputKeyDown}"
403 @input="${this._chatInputChanged}"
404 .value=${this.content || ""}
405 ></textarea>
Pokey Rule044a62e2025-05-16 10:40:59 +0000406 <button
407 @click="${this._sendChatClicked}"
408 id="sendChatButton"
409 ?disabled=${this.uploadsInProgress > 0}
410 >
411 ${this.uploadsInProgress > 0 ? "Uploading..." : "Send"}
Sean McCullough86b56862025-04-18 13:04:03 -0700412 </button>
413 </div>
Pokey Rule339b56e2025-05-15 14:48:07 +0000414 ${this.isDraggingOver
415 ? html`
416 <div class="drop-zone-overlay">
417 <div class="drop-zone-message">Drop files here</div>
418 </div>
419 `
420 : ""}
Pokey Rule044a62e2025-05-16 10:40:59 +0000421 ${this.showUploadInProgressMessage
422 ? html`
423 <div class="upload-progress-message">
424 Please wait for file upload to complete before sending
425 </div>
426 `
427 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -0700428 </div>
429 `;
430 }
431}
432
433declare global {
434 interface HTMLElementTagNameMap {
435 "sketch-chat-input": SketchChatInput;
436 }
437}