blob: 69dd44ca922e71357a3de90dcc8e05ed8bf92dca [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
Sean McCullough86b56862025-04-18 13:04:03 -070012 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
13 // Note that these styles only apply to the scope of this web component's
14 // shadow DOM node, so they won't leak out or collide with CSS declared in
15 // other components or the containing web page (...unless you want it to do that).
16 static styles = css`
17 /* Chat styles - exactly matching timeline.css */
18 .chat-container {
Sean McCullough86b56862025-04-18 13:04:03 -070019 width: 100%;
20 background: #f0f0f0;
21 padding: 15px;
Sean McCullough86b56862025-04-18 13:04:03 -070022 min-height: 40px; /* Ensure minimum height */
Pokey Rule339b56e2025-05-15 14:48:07 +000023 position: relative;
Sean McCullough86b56862025-04-18 13:04:03 -070024 }
25
26 .chat-input-wrapper {
27 display: flex;
28 max-width: 1200px;
29 margin: 0 auto;
30 gap: 10px;
31 }
32
33 #chatInput {
34 flex: 1;
35 padding: 12px;
36 border: 1px solid #ddd;
37 border-radius: 4px;
Sean McCullough07b3e392025-04-21 22:51:14 +000038 resize: vertical;
Sean McCullough86b56862025-04-18 13:04:03 -070039 font-family: monospace;
40 font-size: 12px;
41 min-height: 40px;
Sean McCullough07b3e392025-04-21 22:51:14 +000042 max-height: 300px;
Sean McCullough86b56862025-04-18 13:04:03 -070043 background: #f7f7f7;
Sean McCullough5164eee2025-04-21 18:20:23 -070044 overflow-y: auto;
Sean McCullough07b3e392025-04-21 22:51:14 +000045 box-sizing: border-box; /* Ensure padding is included in height calculation */
46 line-height: 1.4; /* Consistent line height for better height calculation */
Sean McCullough86b56862025-04-18 13:04:03 -070047 }
48
49 #sendChatButton {
50 background-color: #2196f3;
51 color: white;
52 border: none;
53 border-radius: 4px;
54 padding: 0 20px;
55 cursor: pointer;
56 font-weight: 600;
Pokey Rule97188fc2025-04-23 15:50:04 +010057 align-self: center;
58 height: 40px;
Sean McCullough86b56862025-04-18 13:04:03 -070059 }
60
61 #sendChatButton:hover {
62 background-color: #0d8bf2;
63 }
Pokey Rule339b56e2025-05-15 14:48:07 +000064
65 /* Drop zone styling */
66 .drop-zone-overlay {
67 position: absolute;
68 top: 0;
69 left: 0;
70 right: 0;
71 bottom: 0;
72 background-color: rgba(33, 150, 243, 0.1);
73 border: 2px dashed #2196f3;
74 border-radius: 4px;
75 display: flex;
76 justify-content: center;
77 align-items: center;
78 z-index: 10;
79 pointer-events: none;
80 }
81
82 .drop-zone-message {
83 background-color: #ffffff;
84 padding: 15px 20px;
85 border-radius: 4px;
86 font-weight: 600;
87 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
88 }
Sean McCullough86b56862025-04-18 13:04:03 -070089 `;
90
91 constructor() {
92 super();
Philip Zeyliger73db6052025-04-23 13:09:07 -070093 this._handleDiffComment = this._handleDiffComment.bind(this);
Pokey Rule339b56e2025-05-15 14:48:07 +000094 this._handleDragOver = this._handleDragOver.bind(this);
95 this._handleDragEnter = this._handleDragEnter.bind(this);
96 this._handleDragLeave = this._handleDragLeave.bind(this);
97 this._handleDrop = this._handleDrop.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -070098 }
99
Sean McCullough86b56862025-04-18 13:04:03 -0700100 connectedCallback() {
101 super.connectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700102 window.addEventListener("diff-comment", this._handleDiffComment);
103 }
104
Pokey Rule339b56e2025-05-15 14:48:07 +0000105 // Utility function to handle file uploads (used by both paste and drop handlers)
106 private async _uploadFile(file: File, insertPosition: number) {
107 // Insert a placeholder at the cursor position
108 const textBefore = this.content.substring(0, insertPosition);
109 const textAfter = this.content.substring(insertPosition);
110
111 // Add a loading indicator
112 const loadingText = `[Uploading ${file.name}...]`;
113 this.content = `${textBefore}${loadingText}${textAfter}`;
114
115 // Adjust spacing immediately to show loading indicator
116 requestAnimationFrame(() => this.adjustChatSpacing());
117
118 try {
119 // Create a FormData object to send the file
120 const formData = new FormData();
121 formData.append("file", file);
122
123 // Upload the file to the server using a relative path
124 const response = await fetch("./upload", {
125 method: "POST",
126 body: formData,
127 });
128
129 if (!response.ok) {
130 throw new Error(`Upload failed: ${response.statusText}`);
131 }
132
133 const data = await response.json();
134
135 // Replace the loading placeholder with the actual file path
136 this.content = this.content.replace(loadingText, `[${data.path}]`);
137
138 // Adjust the cursor position after the inserted text
139 requestAnimationFrame(() => {
140 this.adjustChatSpacing();
141 this.chatInput.focus();
142 const newPos = textBefore.length + data.path.length + 2; // +2 for the brackets
143 this.chatInput.selectionStart = newPos;
144 this.chatInput.selectionEnd = newPos;
145 });
146
147 return data.path;
148 } catch (error) {
149 console.error("Failed to upload file:", error);
150
151 // Replace loading indicator with error message
152 const errorText = `[Upload failed: ${error.message}]`;
153 this.content = this.content.replace(loadingText, errorText);
154
155 // Adjust spacing to show error message
156 requestAnimationFrame(() => {
157 this.adjustChatSpacing();
158 this.chatInput.focus();
159 });
160
161 throw error;
162 }
163 }
164
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000165 // Handle paste events for files (including images)
166 private _handlePaste = async (event: ClipboardEvent) => {
167 // Check if the clipboard contains files
168 if (event.clipboardData && event.clipboardData.files.length > 0) {
169 const file = event.clipboardData.files[0];
170
171 // Handle the file upload (for any file type, not just images)
172 event.preventDefault(); // Prevent default paste behavior
173
Pokey Rule339b56e2025-05-15 14:48:07 +0000174 // Get the current cursor position
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000175 const cursorPos = this.chatInput.selectionStart;
Pokey Rule339b56e2025-05-15 14:48:07 +0000176 await this._uploadFile(file, cursorPos);
177 }
178 };
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000179
Pokey Rule339b56e2025-05-15 14:48:07 +0000180 // Handle drag events for file drop operation
181 private _handleDragOver(event: DragEvent) {
182 event.preventDefault(); // Necessary to allow dropping
183 event.stopPropagation();
184 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000185
Pokey Rule339b56e2025-05-15 14:48:07 +0000186 private _handleDragEnter(event: DragEvent) {
187 event.preventDefault();
188 event.stopPropagation();
189 this.isDraggingOver = true;
190 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000191
Pokey Rule339b56e2025-05-15 14:48:07 +0000192 private _handleDragLeave(event: DragEvent) {
193 event.preventDefault();
194 event.stopPropagation();
195 // Only set to false if we're leaving the container (not entering a child element)
196 if (event.target === this.renderRoot.querySelector(".chat-container")) {
197 this.isDraggingOver = false;
198 }
199 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000200
Pokey Rule339b56e2025-05-15 14:48:07 +0000201 private _handleDrop = async (event: DragEvent) => {
202 event.preventDefault();
203 event.stopPropagation();
204 this.isDraggingOver = false;
205
206 // Check if the dataTransfer contains files
207 if (event.dataTransfer && event.dataTransfer.files.length > 0) {
208 // Process all dropped files
209 for (let i = 0; i < event.dataTransfer.files.length; i++) {
210 const file = event.dataTransfer.files[i];
211 try {
212 // For the first file, insert at the cursor position
213 // For subsequent files, append at the end of the content
214 const insertPosition =
215 i === 0 ? this.chatInput.selectionStart : this.content.length;
216 await this._uploadFile(file, insertPosition);
217
218 // Add a space between multiple files
219 if (i < event.dataTransfer.files.length - 1) {
220 this.content += " ";
221 }
222 } catch (error) {
223 // Error already handled in _uploadFile
224 console.error("Failed to process dropped file:", error);
225 // Continue with the next file
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000226 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000227 }
228 }
229 };
230
Philip Zeyliger73db6052025-04-23 13:09:07 -0700231 private _handleDiffComment(event: CustomEvent) {
232 const { comment } = event.detail;
233 if (!comment) return;
234
235 if (this.content != "") {
236 this.content += "\n\n";
237 }
238 this.content += comment;
239 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700240 }
241
242 // See https://lit.dev/docs/components/lifecycle/
243 disconnectedCallback() {
244 super.disconnectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700245 window.removeEventListener("diff-comment", this._handleDiffComment);
Pokey Rule339b56e2025-05-15 14:48:07 +0000246
247 // Clean up drag and drop event listeners
248 const container = this.renderRoot.querySelector(".chat-container");
249 if (container) {
250 container.removeEventListener("dragover", this._handleDragOver);
251 container.removeEventListener("dragenter", this._handleDragEnter);
252 container.removeEventListener("dragleave", this._handleDragLeave);
253 container.removeEventListener("drop", this._handleDrop);
254 }
255
256 // Clean up paste event listener
257 if (this.chatInput) {
258 this.chatInput.removeEventListener("paste", this._handlePaste);
259 }
Sean McCullough86b56862025-04-18 13:04:03 -0700260 }
261
262 sendChatMessage() {
263 const event = new CustomEvent("send-chat", {
264 detail: { message: this.content },
265 bubbles: true,
266 composed: true,
267 });
268 this.dispatchEvent(event);
Philip Zeyliger73db6052025-04-23 13:09:07 -0700269
270 // TODO(philip?): Ideally we only clear the content if the send is successful.
271 this.content = ""; // Clear content after sending
Sean McCullough86b56862025-04-18 13:04:03 -0700272 }
273
274 adjustChatSpacing() {
Sean McCullough07b3e392025-04-21 22:51:14 +0000275 if (!this.chatInput) return;
Sean McCullough5164eee2025-04-21 18:20:23 -0700276
Sean McCullough07b3e392025-04-21 22:51:14 +0000277 // Reset height to minimal value to correctly calculate scrollHeight
Sean McCullough5164eee2025-04-21 18:20:23 -0700278 this.chatInput.style.height = "auto";
279
Sean McCullough07b3e392025-04-21 22:51:14 +0000280 // Get the scroll height (content height)
281 const scrollHeight = this.chatInput.scrollHeight;
Sean McCullough5164eee2025-04-21 18:20:23 -0700282
Sean McCullough07b3e392025-04-21 22:51:14 +0000283 // Set the height to match content (up to max-height which is handled by CSS)
284 this.chatInput.style.height = `${scrollHeight}px`;
Sean McCullough86b56862025-04-18 13:04:03 -0700285 }
286
Philip Zeyliger73db6052025-04-23 13:09:07 -0700287 async _sendChatClicked() {
Sean McCullough86b56862025-04-18 13:04:03 -0700288 this.sendChatMessage();
289 this.chatInput.focus(); // Refocus the input after sending
Sean McCullough07b3e392025-04-21 22:51:14 +0000290 // Reset height after sending a message
291 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700292 }
293
294 _chatInputKeyDown(event: KeyboardEvent) {
295 // Send message if Enter is pressed without Shift key
296 if (event.key === "Enter" && !event.shiftKey) {
297 event.preventDefault(); // Prevent default newline
298 this.sendChatMessage();
299 }
300 }
301
302 _chatInputChanged(event) {
303 this.content = event.target.value;
Sean McCullough07b3e392025-04-21 22:51:14 +0000304 // Use requestAnimationFrame to ensure DOM updates have completed
Sean McCullough86b56862025-04-18 13:04:03 -0700305 requestAnimationFrame(() => this.adjustChatSpacing());
306 }
307
308 @query("#chatInput")
Sean McCullough07b3e392025-04-21 22:51:14 +0000309 chatInput: HTMLTextAreaElement;
Sean McCullough86b56862025-04-18 13:04:03 -0700310
311 protected firstUpdated(): void {
312 if (this.chatInput) {
313 this.chatInput.focus();
Sean McCullough07b3e392025-04-21 22:51:14 +0000314 // Initialize the input height
315 this.adjustChatSpacing();
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000316
317 // Add paste event listener for image handling
318 this.chatInput.addEventListener("paste", this._handlePaste);
Pokey Rule339b56e2025-05-15 14:48:07 +0000319
320 // Add drag and drop event listeners
321 const container = this.renderRoot.querySelector(".chat-container");
322 if (container) {
323 container.addEventListener("dragover", this._handleDragOver);
324 container.addEventListener("dragenter", this._handleDragEnter);
325 container.addEventListener("dragleave", this._handleDragLeave);
326 container.addEventListener("drop", this._handleDrop);
327 }
Sean McCullough86b56862025-04-18 13:04:03 -0700328 }
Josh Bleecher Snydere2c7f722025-05-01 21:58:41 +0000329
330 // Add window.onload handler to ensure the input is focused when the page fully loads
331 window.addEventListener(
332 "load",
333 () => {
334 if (this.chatInput) {
335 this.chatInput.focus();
336 }
337 },
338 { once: true },
339 );
Sean McCullough86b56862025-04-18 13:04:03 -0700340 }
341
342 render() {
343 return html`
344 <div class="chat-container">
345 <div class="chat-input-wrapper">
346 <textarea
347 id="chatInput"
348 placeholder="Type your message here and press Enter to send..."
349 autofocus
350 @keydown="${this._chatInputKeyDown}"
351 @input="${this._chatInputChanged}"
352 .value=${this.content || ""}
353 ></textarea>
354 <button @click="${this._sendChatClicked}" id="sendChatButton">
355 Send
356 </button>
357 </div>
Pokey Rule339b56e2025-05-15 14:48:07 +0000358 ${this.isDraggingOver
359 ? html`
360 <div class="drop-zone-overlay">
361 <div class="drop-zone-message">Drop files here</div>
362 </div>
363 `
364 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -0700365 </div>
366 `;
367 }
368}
369
370declare global {
371 interface HTMLElementTagNameMap {
372 "sketch-chat-input": SketchChatInput;
373 }
374}