blob: ca6afbfacfb73cb97f9aee035179dafa7c4b1181 [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
176 // Adjust the cursor position after the inserted text
177 requestAnimationFrame(() => {
178 this.adjustChatSpacing();
179 this.chatInput.focus();
180 const newPos = textBefore.length + data.path.length + 2; // +2 for the brackets
181 this.chatInput.selectionStart = newPos;
182 this.chatInput.selectionEnd = newPos;
183 });
184
185 return data.path;
186 } catch (error) {
187 console.error("Failed to upload file:", error);
188
189 // Replace loading indicator with error message
190 const errorText = `[Upload failed: ${error.message}]`;
191 this.content = this.content.replace(loadingText, errorText);
192
193 // Adjust spacing to show error message
194 requestAnimationFrame(() => {
195 this.adjustChatSpacing();
196 this.chatInput.focus();
197 });
198
199 throw error;
Pokey Rule044a62e2025-05-16 10:40:59 +0000200 } finally {
201 // Always decrement the counter, even if there was an error
202 this.uploadsInProgress--;
Pokey Rule339b56e2025-05-15 14:48:07 +0000203 }
204 }
205
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000206 // Handle paste events for files (including images)
207 private _handlePaste = async (event: ClipboardEvent) => {
208 // Check if the clipboard contains files
209 if (event.clipboardData && event.clipboardData.files.length > 0) {
210 const file = event.clipboardData.files[0];
211
212 // Handle the file upload (for any file type, not just images)
213 event.preventDefault(); // Prevent default paste behavior
214
Pokey Rule339b56e2025-05-15 14:48:07 +0000215 // Get the current cursor position
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000216 const cursorPos = this.chatInput.selectionStart;
Pokey Rule339b56e2025-05-15 14:48:07 +0000217 await this._uploadFile(file, cursorPos);
218 }
219 };
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000220
Pokey Rule339b56e2025-05-15 14:48:07 +0000221 // Handle drag events for file drop operation
222 private _handleDragOver(event: DragEvent) {
223 event.preventDefault(); // Necessary to allow dropping
224 event.stopPropagation();
225 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000226
Pokey Rule339b56e2025-05-15 14:48:07 +0000227 private _handleDragEnter(event: DragEvent) {
228 event.preventDefault();
229 event.stopPropagation();
230 this.isDraggingOver = true;
231 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000232
Pokey Rule339b56e2025-05-15 14:48:07 +0000233 private _handleDragLeave(event: DragEvent) {
234 event.preventDefault();
235 event.stopPropagation();
236 // Only set to false if we're leaving the container (not entering a child element)
237 if (event.target === this.renderRoot.querySelector(".chat-container")) {
238 this.isDraggingOver = false;
239 }
240 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000241
Pokey Rule339b56e2025-05-15 14:48:07 +0000242 private _handleDrop = async (event: DragEvent) => {
243 event.preventDefault();
244 event.stopPropagation();
245 this.isDraggingOver = false;
246
247 // Check if the dataTransfer contains files
248 if (event.dataTransfer && event.dataTransfer.files.length > 0) {
249 // Process all dropped files
250 for (let i = 0; i < event.dataTransfer.files.length; i++) {
251 const file = event.dataTransfer.files[i];
252 try {
253 // For the first file, insert at the cursor position
254 // For subsequent files, append at the end of the content
255 const insertPosition =
256 i === 0 ? this.chatInput.selectionStart : this.content.length;
257 await this._uploadFile(file, insertPosition);
258
259 // Add a space between multiple files
260 if (i < event.dataTransfer.files.length - 1) {
261 this.content += " ";
262 }
263 } catch (error) {
264 // Error already handled in _uploadFile
265 console.error("Failed to process dropped file:", error);
266 // Continue with the next file
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000267 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000268 }
269 }
270 };
271
Philip Zeyliger73db6052025-04-23 13:09:07 -0700272 private _handleDiffComment(event: CustomEvent) {
273 const { comment } = event.detail;
274 if (!comment) return;
275
276 if (this.content != "") {
277 this.content += "\n\n";
278 }
279 this.content += comment;
280 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700281 }
282
283 // See https://lit.dev/docs/components/lifecycle/
284 disconnectedCallback() {
285 super.disconnectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700286 window.removeEventListener("diff-comment", this._handleDiffComment);
Pokey Rule339b56e2025-05-15 14:48:07 +0000287
288 // Clean up drag and drop event listeners
289 const container = this.renderRoot.querySelector(".chat-container");
290 if (container) {
291 container.removeEventListener("dragover", this._handleDragOver);
292 container.removeEventListener("dragenter", this._handleDragEnter);
293 container.removeEventListener("dragleave", this._handleDragLeave);
294 container.removeEventListener("drop", this._handleDrop);
295 }
296
297 // Clean up paste event listener
298 if (this.chatInput) {
299 this.chatInput.removeEventListener("paste", this._handlePaste);
300 }
Sean McCullough86b56862025-04-18 13:04:03 -0700301 }
302
303 sendChatMessage() {
Pokey Rule044a62e2025-05-16 10:40:59 +0000304 // Prevent sending if there are uploads in progress
305 if (this.uploadsInProgress > 0) {
306 console.log(
307 `Message send prevented: ${this.uploadsInProgress} uploads in progress`,
308 );
Philip Zeyliger73db6052025-04-23 13:09:07 -0700309
Pokey Rule044a62e2025-05-16 10:40:59 +0000310 // Show message to user
311 this.showUploadInProgressMessage = true;
312
313 // Hide the message after 3 seconds
314 setTimeout(() => {
315 this.showUploadInProgressMessage = false;
316 }, 3000);
317
318 return;
319 }
320
321 // Only send if there's actual content (not just whitespace)
322 if (this.content.trim()) {
323 const event = new CustomEvent("send-chat", {
324 detail: { message: this.content },
325 bubbles: true,
326 composed: true,
327 });
328 this.dispatchEvent(event);
329
330 // TODO(philip?): Ideally we only clear the content if the send is successful.
331 this.content = ""; // Clear content after sending
332 }
Sean McCullough86b56862025-04-18 13:04:03 -0700333 }
334
335 adjustChatSpacing() {
Sean McCullough07b3e392025-04-21 22:51:14 +0000336 if (!this.chatInput) return;
Sean McCullough5164eee2025-04-21 18:20:23 -0700337
Sean McCullough07b3e392025-04-21 22:51:14 +0000338 // Reset height to minimal value to correctly calculate scrollHeight
Sean McCullough5164eee2025-04-21 18:20:23 -0700339 this.chatInput.style.height = "auto";
340
Sean McCullough07b3e392025-04-21 22:51:14 +0000341 // Get the scroll height (content height)
342 const scrollHeight = this.chatInput.scrollHeight;
Sean McCullough5164eee2025-04-21 18:20:23 -0700343
Sean McCullough07b3e392025-04-21 22:51:14 +0000344 // Set the height to match content (up to max-height which is handled by CSS)
345 this.chatInput.style.height = `${scrollHeight}px`;
Sean McCullough86b56862025-04-18 13:04:03 -0700346 }
347
Philip Zeyliger73db6052025-04-23 13:09:07 -0700348 async _sendChatClicked() {
Sean McCullough86b56862025-04-18 13:04:03 -0700349 this.sendChatMessage();
350 this.chatInput.focus(); // Refocus the input after sending
Sean McCullough07b3e392025-04-21 22:51:14 +0000351 // Reset height after sending a message
352 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700353 }
354
355 _chatInputKeyDown(event: KeyboardEvent) {
356 // Send message if Enter is pressed without Shift key
357 if (event.key === "Enter" && !event.shiftKey) {
358 event.preventDefault(); // Prevent default newline
359 this.sendChatMessage();
360 }
361 }
362
363 _chatInputChanged(event) {
364 this.content = event.target.value;
Sean McCullough07b3e392025-04-21 22:51:14 +0000365 // Use requestAnimationFrame to ensure DOM updates have completed
Sean McCullough86b56862025-04-18 13:04:03 -0700366 requestAnimationFrame(() => this.adjustChatSpacing());
367 }
368
369 @query("#chatInput")
Sean McCullough07b3e392025-04-21 22:51:14 +0000370 chatInput: HTMLTextAreaElement;
Sean McCullough86b56862025-04-18 13:04:03 -0700371
372 protected firstUpdated(): void {
373 if (this.chatInput) {
374 this.chatInput.focus();
Sean McCullough07b3e392025-04-21 22:51:14 +0000375 // Initialize the input height
376 this.adjustChatSpacing();
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000377
378 // Add paste event listener for image handling
379 this.chatInput.addEventListener("paste", this._handlePaste);
Pokey Rule339b56e2025-05-15 14:48:07 +0000380
381 // Add drag and drop event listeners
382 const container = this.renderRoot.querySelector(".chat-container");
383 if (container) {
384 container.addEventListener("dragover", this._handleDragOver);
385 container.addEventListener("dragenter", this._handleDragEnter);
386 container.addEventListener("dragleave", this._handleDragLeave);
387 container.addEventListener("drop", this._handleDrop);
388 }
Sean McCullough86b56862025-04-18 13:04:03 -0700389 }
Josh Bleecher Snydere2c7f722025-05-01 21:58:41 +0000390
391 // Add window.onload handler to ensure the input is focused when the page fully loads
392 window.addEventListener(
393 "load",
394 () => {
395 if (this.chatInput) {
396 this.chatInput.focus();
397 }
398 },
399 { once: true },
400 );
Sean McCullough86b56862025-04-18 13:04:03 -0700401 }
402
403 render() {
404 return html`
405 <div class="chat-container">
406 <div class="chat-input-wrapper">
407 <textarea
408 id="chatInput"
409 placeholder="Type your message here and press Enter to send..."
410 autofocus
411 @keydown="${this._chatInputKeyDown}"
412 @input="${this._chatInputChanged}"
413 .value=${this.content || ""}
414 ></textarea>
Pokey Rule044a62e2025-05-16 10:40:59 +0000415 <button
416 @click="${this._sendChatClicked}"
417 id="sendChatButton"
418 ?disabled=${this.uploadsInProgress > 0}
419 >
420 ${this.uploadsInProgress > 0 ? "Uploading..." : "Send"}
Sean McCullough86b56862025-04-18 13:04:03 -0700421 </button>
422 </div>
Pokey Rule339b56e2025-05-15 14:48:07 +0000423 ${this.isDraggingOver
424 ? html`
425 <div class="drop-zone-overlay">
426 <div class="drop-zone-message">Drop files here</div>
427 </div>
428 `
429 : ""}
Pokey Rule044a62e2025-05-16 10:40:59 +0000430 ${this.showUploadInProgressMessage
431 ? html`
432 <div class="upload-progress-message">
433 Please wait for file upload to complete before sending
434 </div>
435 `
436 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -0700437 </div>
438 `;
439 }
440}
441
442declare global {
443 interface HTMLElementTagNameMap {
444 "sketch-chat-input": SketchChatInput;
445 }
446}