| import { css, html, LitElement, PropertyValues } from "lit"; |
| import { customElement, property, state, query } from "lit/decorators.js"; |
| |
| @customElement("sketch-chat-input") |
| export class SketchChatInput extends LitElement { |
| @state() |
| content: string = ""; |
| |
| @state() |
| isDraggingOver: boolean = false; |
| |
| @state() |
| uploadsInProgress: number = 0; |
| |
| @state() |
| showUploadInProgressMessage: boolean = false; |
| |
| // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS. |
| // Note that these styles only apply to the scope of this web component's |
| // shadow DOM node, so they won't leak out or collide with CSS declared in |
| // other components or the containing web page (...unless you want it to do that). |
| static styles = css` |
| /* Chat styles - exactly matching timeline.css */ |
| .chat-container { |
| width: 100%; |
| background: #f0f0f0; |
| padding: 15px; |
| min-height: 40px; /* Ensure minimum height */ |
| position: relative; |
| } |
| |
| .chat-input-wrapper { |
| display: flex; |
| max-width: 1200px; |
| margin: 0 auto; |
| gap: 10px; |
| } |
| |
| #chatInput { |
| flex: 1; |
| padding: 12px; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| resize: vertical; |
| font-family: monospace; |
| font-size: 12px; |
| min-height: 40px; |
| max-height: 300px; |
| background: #f7f7f7; |
| overflow-y: auto; |
| box-sizing: border-box; /* Ensure padding is included in height calculation */ |
| line-height: 1.4; /* Consistent line height for better height calculation */ |
| } |
| |
| #sendChatButton { |
| background-color: #2196f3; |
| color: white; |
| border: none; |
| border-radius: 4px; |
| padding: 0 20px; |
| cursor: pointer; |
| font-weight: 600; |
| align-self: center; |
| height: 40px; |
| } |
| |
| #sendChatButton:hover { |
| background-color: #0d8bf2; |
| } |
| |
| #sendChatButton:disabled { |
| background-color: #b0b0b0; |
| cursor: not-allowed; |
| } |
| |
| /* Drop zone styling */ |
| .drop-zone-overlay { |
| position: absolute; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background-color: rgba(33, 150, 243, 0.1); |
| border: 2px dashed #2196f3; |
| border-radius: 4px; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| z-index: 10; |
| pointer-events: none; |
| } |
| |
| .drop-zone-message, |
| .upload-progress-message { |
| background-color: #ffffff; |
| padding: 15px 20px; |
| border-radius: 4px; |
| font-weight: 600; |
| box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); |
| } |
| |
| .upload-progress-message { |
| position: absolute; |
| bottom: 70px; |
| left: 50%; |
| transform: translateX(-50%); |
| background-color: #fff9c4; |
| border: 1px solid #fbc02d; |
| z-index: 20; |
| font-size: 14px; |
| animation: fadeIn 0.3s ease-in-out; |
| } |
| |
| @keyframes fadeIn { |
| from { |
| opacity: 0; |
| transform: translateX(-50%) translateY(10px); |
| } |
| to { |
| opacity: 1; |
| transform: translateX(-50%) translateY(0); |
| } |
| } |
| `; |
| |
| constructor() { |
| super(); |
| this._handleDiffComment = this._handleDiffComment.bind(this); |
| this._handleDragOver = this._handleDragOver.bind(this); |
| this._handleDragEnter = this._handleDragEnter.bind(this); |
| this._handleDragLeave = this._handleDragLeave.bind(this); |
| this._handleDrop = this._handleDrop.bind(this); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| window.addEventListener("diff-comment", this._handleDiffComment); |
| } |
| |
| // Utility function to handle file uploads (used by both paste and drop handlers) |
| private async _uploadFile(file: File, insertPosition: number) { |
| // Insert a placeholder at the cursor position |
| const textBefore = this.content.substring(0, insertPosition); |
| const textAfter = this.content.substring(insertPosition); |
| |
| // Add a loading indicator with a visual cue |
| const loadingText = `[🔄 Uploading ${file.name}...]`; |
| this.content = `${textBefore}${loadingText}${textAfter}`; |
| |
| // Increment uploads in progress counter |
| this.uploadsInProgress++; |
| |
| // Adjust spacing immediately to show loading indicator |
| requestAnimationFrame(() => this.adjustChatSpacing()); |
| |
| try { |
| // Create a FormData object to send the file |
| const formData = new FormData(); |
| formData.append("file", file); |
| |
| // Upload the file to the server using a relative path |
| const response = await fetch("./upload", { |
| method: "POST", |
| body: formData, |
| }); |
| |
| if (!response.ok) { |
| throw new Error(`Upload failed: ${response.statusText}`); |
| } |
| |
| const data = await response.json(); |
| |
| // Replace the loading placeholder with the actual file path |
| this.content = this.content.replace(loadingText, `[${data.path}]`); |
| |
| return data.path; |
| } catch (error) { |
| console.error("Failed to upload file:", error); |
| |
| // Replace loading indicator with error message |
| const errorText = `[Upload failed: ${error.message}]`; |
| this.content = this.content.replace(loadingText, errorText); |
| |
| // Adjust spacing to show error message |
| requestAnimationFrame(() => { |
| this.adjustChatSpacing(); |
| this.chatInput.focus(); |
| }); |
| |
| throw error; |
| } finally { |
| // Always decrement the counter, even if there was an error |
| this.uploadsInProgress--; |
| } |
| } |
| |
| // Handle paste events for files (including images) |
| private _handlePaste = async (event: ClipboardEvent) => { |
| // Check if the clipboard contains files |
| if (event.clipboardData && event.clipboardData.files.length > 0) { |
| const file = event.clipboardData.files[0]; |
| |
| // Handle the file upload (for any file type, not just images) |
| event.preventDefault(); // Prevent default paste behavior |
| |
| // Get the current cursor position |
| const cursorPos = this.chatInput.selectionStart; |
| await this._uploadFile(file, cursorPos); |
| } |
| }; |
| |
| // Handle drag events for file drop operation |
| private _handleDragOver(event: DragEvent) { |
| event.preventDefault(); // Necessary to allow dropping |
| event.stopPropagation(); |
| } |
| |
| private _handleDragEnter(event: DragEvent) { |
| event.preventDefault(); |
| event.stopPropagation(); |
| this.isDraggingOver = true; |
| } |
| |
| private _handleDragLeave(event: DragEvent) { |
| event.preventDefault(); |
| event.stopPropagation(); |
| // Only set to false if we're leaving the container (not entering a child element) |
| if (event.target === this.renderRoot.querySelector(".chat-container")) { |
| this.isDraggingOver = false; |
| } |
| } |
| |
| private _handleDrop = async (event: DragEvent) => { |
| event.preventDefault(); |
| event.stopPropagation(); |
| this.isDraggingOver = false; |
| |
| // Check if the dataTransfer contains files |
| if (event.dataTransfer && event.dataTransfer.files.length > 0) { |
| // Process all dropped files |
| for (let i = 0; i < event.dataTransfer.files.length; i++) { |
| const file = event.dataTransfer.files[i]; |
| try { |
| // For the first file, insert at the cursor position |
| // For subsequent files, append at the end of the content |
| const insertPosition = |
| i === 0 ? this.chatInput.selectionStart : this.content.length; |
| await this._uploadFile(file, insertPosition); |
| |
| // Add a space between multiple files |
| if (i < event.dataTransfer.files.length - 1) { |
| this.content += " "; |
| } |
| } catch (error) { |
| // Error already handled in _uploadFile |
| console.error("Failed to process dropped file:", error); |
| // Continue with the next file |
| } |
| } |
| } |
| }; |
| |
| private _handleDiffComment(event: CustomEvent) { |
| const { comment } = event.detail; |
| if (!comment) return; |
| |
| if (this.content != "") { |
| this.content += "\n\n"; |
| } |
| this.content += comment; |
| requestAnimationFrame(() => this.adjustChatSpacing()); |
| } |
| |
| // See https://lit.dev/docs/components/lifecycle/ |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| window.removeEventListener("diff-comment", this._handleDiffComment); |
| |
| // Clean up drag and drop event listeners |
| const container = this.renderRoot.querySelector(".chat-container"); |
| if (container) { |
| container.removeEventListener("dragover", this._handleDragOver); |
| container.removeEventListener("dragenter", this._handleDragEnter); |
| container.removeEventListener("dragleave", this._handleDragLeave); |
| container.removeEventListener("drop", this._handleDrop); |
| } |
| |
| // Clean up paste event listener |
| if (this.chatInput) { |
| this.chatInput.removeEventListener("paste", this._handlePaste); |
| } |
| } |
| |
| sendChatMessage() { |
| // Prevent sending if there are uploads in progress |
| if (this.uploadsInProgress > 0) { |
| console.log( |
| `Message send prevented: ${this.uploadsInProgress} uploads in progress`, |
| ); |
| |
| // Show message to user |
| this.showUploadInProgressMessage = true; |
| |
| // Hide the message after 3 seconds |
| setTimeout(() => { |
| this.showUploadInProgressMessage = false; |
| }, 3000); |
| |
| return; |
| } |
| |
| // Only send if there's actual content (not just whitespace) |
| if (this.content.trim()) { |
| const event = new CustomEvent("send-chat", { |
| detail: { message: this.content }, |
| bubbles: true, |
| composed: true, |
| }); |
| this.dispatchEvent(event); |
| |
| // TODO(philip?): Ideally we only clear the content if the send is successful. |
| this.content = ""; // Clear content after sending |
| } |
| } |
| |
| adjustChatSpacing() { |
| if (!this.chatInput) return; |
| |
| // Reset height to minimal value to correctly calculate scrollHeight |
| this.chatInput.style.height = "auto"; |
| |
| // Get the scroll height (content height) |
| const scrollHeight = this.chatInput.scrollHeight; |
| |
| // Set the height to match content (up to max-height which is handled by CSS) |
| this.chatInput.style.height = `${scrollHeight}px`; |
| } |
| |
| async _sendChatClicked() { |
| this.sendChatMessage(); |
| this.chatInput.focus(); // Refocus the input after sending |
| // Reset height after sending a message |
| requestAnimationFrame(() => this.adjustChatSpacing()); |
| } |
| |
| _chatInputKeyDown(event: KeyboardEvent) { |
| // Send message if Enter is pressed without Shift key |
| if (event.key === "Enter" && !event.shiftKey) { |
| event.preventDefault(); // Prevent default newline |
| this.sendChatMessage(); |
| } |
| } |
| |
| _chatInputChanged(event) { |
| this.content = event.target.value; |
| // Use requestAnimationFrame to ensure DOM updates have completed |
| requestAnimationFrame(() => this.adjustChatSpacing()); |
| } |
| |
| @query("#chatInput") |
| chatInput: HTMLTextAreaElement; |
| |
| protected firstUpdated(): void { |
| if (this.chatInput) { |
| this.chatInput.focus(); |
| // Initialize the input height |
| this.adjustChatSpacing(); |
| |
| // Add paste event listener for image handling |
| this.chatInput.addEventListener("paste", this._handlePaste); |
| |
| // Add drag and drop event listeners |
| const container = this.renderRoot.querySelector(".chat-container"); |
| if (container) { |
| container.addEventListener("dragover", this._handleDragOver); |
| container.addEventListener("dragenter", this._handleDragEnter); |
| container.addEventListener("dragleave", this._handleDragLeave); |
| container.addEventListener("drop", this._handleDrop); |
| } |
| } |
| |
| // Add window.onload handler to ensure the input is focused when the page fully loads |
| window.addEventListener( |
| "load", |
| () => { |
| if (this.chatInput) { |
| this.chatInput.focus(); |
| } |
| }, |
| { once: true }, |
| ); |
| } |
| |
| render() { |
| return html` |
| <div class="chat-container"> |
| <div class="chat-input-wrapper"> |
| <textarea |
| id="chatInput" |
| placeholder="Type your message here and press Enter to send..." |
| autofocus |
| @keydown="${this._chatInputKeyDown}" |
| @input="${this._chatInputChanged}" |
| .value=${this.content || ""} |
| ></textarea> |
| <button |
| @click="${this._sendChatClicked}" |
| id="sendChatButton" |
| ?disabled=${this.uploadsInProgress > 0} |
| > |
| ${this.uploadsInProgress > 0 ? "Uploading..." : "Send"} |
| </button> |
| </div> |
| ${this.isDraggingOver |
| ? html` |
| <div class="drop-zone-overlay"> |
| <div class="drop-zone-message">Drop files here</div> |
| </div> |
| ` |
| : ""} |
| ${this.showUploadInProgressMessage |
| ? html` |
| <div class="upload-progress-message"> |
| Please wait for file upload to complete before sending |
| </div> |
| ` |
| : ""} |
| </div> |
| `; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| "sketch-chat-input": SketchChatInput; |
| } |
| } |