| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 1 | import { css, html, LitElement, PropertyValues } from "lit"; |
| Philip Zeyliger | 73db605 | 2025-04-23 13:09:07 -0700 | [diff] [blame] | 2 | import { customElement, property, state, query } from "lit/decorators.js"; |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 3 | |
| 4 | @customElement("sketch-chat-input") |
| 5 | export class SketchChatInput extends LitElement { |
| Philip Zeyliger | 73db605 | 2025-04-23 13:09:07 -0700 | [diff] [blame] | 6 | @state() |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 7 | content: string = ""; |
| 8 | |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 9 | @state() |
| 10 | isDraggingOver: boolean = false; |
| 11 | |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 12 | // 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 McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 19 | width: 100%; |
| 20 | background: #f0f0f0; |
| 21 | padding: 15px; |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 22 | min-height: 40px; /* Ensure minimum height */ |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 23 | position: relative; |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 24 | } |
| 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 McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 38 | resize: vertical; |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 39 | font-family: monospace; |
| 40 | font-size: 12px; |
| 41 | min-height: 40px; |
| Sean McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 42 | max-height: 300px; |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 43 | background: #f7f7f7; |
| Sean McCullough | 5164eee | 2025-04-21 18:20:23 -0700 | [diff] [blame] | 44 | overflow-y: auto; |
| Sean McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 45 | box-sizing: border-box; /* Ensure padding is included in height calculation */ |
| 46 | line-height: 1.4; /* Consistent line height for better height calculation */ |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 47 | } |
| 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 Rule | 97188fc | 2025-04-23 15:50:04 +0100 | [diff] [blame] | 57 | align-self: center; |
| 58 | height: 40px; |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 59 | } |
| 60 | |
| 61 | #sendChatButton:hover { |
| 62 | background-color: #0d8bf2; |
| 63 | } |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 64 | |
| 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 McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 89 | `; |
| 90 | |
| 91 | constructor() { |
| 92 | super(); |
| Philip Zeyliger | 73db605 | 2025-04-23 13:09:07 -0700 | [diff] [blame] | 93 | this._handleDiffComment = this._handleDiffComment.bind(this); |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 94 | 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 McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 98 | } |
| 99 | |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 100 | connectedCallback() { |
| 101 | super.connectedCallback(); |
| Philip Zeyliger | 73db605 | 2025-04-23 13:09:07 -0700 | [diff] [blame] | 102 | window.addEventListener("diff-comment", this._handleDiffComment); |
| 103 | } |
| 104 | |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 105 | // 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 Zeyliger | f84e88c | 2025-05-14 23:19:01 +0000 | [diff] [blame] | 165 | // 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 Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 174 | // Get the current cursor position |
| Philip Zeyliger | f84e88c | 2025-05-14 23:19:01 +0000 | [diff] [blame] | 175 | const cursorPos = this.chatInput.selectionStart; |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 176 | await this._uploadFile(file, cursorPos); |
| 177 | } |
| 178 | }; |
| Philip Zeyliger | f84e88c | 2025-05-14 23:19:01 +0000 | [diff] [blame] | 179 | |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 180 | // Handle drag events for file drop operation |
| 181 | private _handleDragOver(event: DragEvent) { |
| 182 | event.preventDefault(); // Necessary to allow dropping |
| 183 | event.stopPropagation(); |
| 184 | } |
| Philip Zeyliger | f84e88c | 2025-05-14 23:19:01 +0000 | [diff] [blame] | 185 | |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 186 | private _handleDragEnter(event: DragEvent) { |
| 187 | event.preventDefault(); |
| 188 | event.stopPropagation(); |
| 189 | this.isDraggingOver = true; |
| 190 | } |
| Philip Zeyliger | f84e88c | 2025-05-14 23:19:01 +0000 | [diff] [blame] | 191 | |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 192 | 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 Zeyliger | f84e88c | 2025-05-14 23:19:01 +0000 | [diff] [blame] | 200 | |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 201 | 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 Zeyliger | f84e88c | 2025-05-14 23:19:01 +0000 | [diff] [blame] | 226 | } |
| Philip Zeyliger | f84e88c | 2025-05-14 23:19:01 +0000 | [diff] [blame] | 227 | } |
| 228 | } |
| 229 | }; |
| 230 | |
| Philip Zeyliger | 73db605 | 2025-04-23 13:09:07 -0700 | [diff] [blame] | 231 | 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 McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 240 | } |
| 241 | |
| 242 | // See https://lit.dev/docs/components/lifecycle/ |
| 243 | disconnectedCallback() { |
| 244 | super.disconnectedCallback(); |
| Philip Zeyliger | 73db605 | 2025-04-23 13:09:07 -0700 | [diff] [blame] | 245 | window.removeEventListener("diff-comment", this._handleDiffComment); |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 246 | |
| 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 McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 260 | } |
| 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 Zeyliger | 73db605 | 2025-04-23 13:09:07 -0700 | [diff] [blame] | 269 | |
| 270 | // TODO(philip?): Ideally we only clear the content if the send is successful. |
| 271 | this.content = ""; // Clear content after sending |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 272 | } |
| 273 | |
| 274 | adjustChatSpacing() { |
| Sean McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 275 | if (!this.chatInput) return; |
| Sean McCullough | 5164eee | 2025-04-21 18:20:23 -0700 | [diff] [blame] | 276 | |
| Sean McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 277 | // Reset height to minimal value to correctly calculate scrollHeight |
| Sean McCullough | 5164eee | 2025-04-21 18:20:23 -0700 | [diff] [blame] | 278 | this.chatInput.style.height = "auto"; |
| 279 | |
| Sean McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 280 | // Get the scroll height (content height) |
| 281 | const scrollHeight = this.chatInput.scrollHeight; |
| Sean McCullough | 5164eee | 2025-04-21 18:20:23 -0700 | [diff] [blame] | 282 | |
| Sean McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 283 | // Set the height to match content (up to max-height which is handled by CSS) |
| 284 | this.chatInput.style.height = `${scrollHeight}px`; |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 285 | } |
| 286 | |
| Philip Zeyliger | 73db605 | 2025-04-23 13:09:07 -0700 | [diff] [blame] | 287 | async _sendChatClicked() { |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 288 | this.sendChatMessage(); |
| 289 | this.chatInput.focus(); // Refocus the input after sending |
| Sean McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 290 | // Reset height after sending a message |
| 291 | requestAnimationFrame(() => this.adjustChatSpacing()); |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 292 | } |
| 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 McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 304 | // Use requestAnimationFrame to ensure DOM updates have completed |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 305 | requestAnimationFrame(() => this.adjustChatSpacing()); |
| 306 | } |
| 307 | |
| 308 | @query("#chatInput") |
| Sean McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 309 | chatInput: HTMLTextAreaElement; |
| Sean McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 310 | |
| 311 | protected firstUpdated(): void { |
| 312 | if (this.chatInput) { |
| 313 | this.chatInput.focus(); |
| Sean McCullough | 07b3e39 | 2025-04-21 22:51:14 +0000 | [diff] [blame] | 314 | // Initialize the input height |
| 315 | this.adjustChatSpacing(); |
| Philip Zeyliger | f84e88c | 2025-05-14 23:19:01 +0000 | [diff] [blame] | 316 | |
| 317 | // Add paste event listener for image handling |
| 318 | this.chatInput.addEventListener("paste", this._handlePaste); |
| Pokey Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 319 | |
| 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 McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 328 | } |
| Josh Bleecher Snyder | e2c7f72 | 2025-05-01 21:58:41 +0000 | [diff] [blame] | 329 | |
| 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 McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 340 | } |
| 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 Rule | 339b56e | 2025-05-15 14:48:07 +0000 | [diff] [blame] | 358 | ${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 McCullough | 86b5686 | 2025-04-18 13:04:03 -0700 | [diff] [blame] | 365 | </div> |
| 366 | `; |
| 367 | } |
| 368 | } |
| 369 | |
| 370 | declare global { |
| 371 | interface HTMLElementTagNameMap { |
| 372 | "sketch-chat-input": SketchChatInput; |
| 373 | } |
| 374 | } |