webui: Implement drag-and-drop file upload in chat window

Fixes https://github.com/boldsoftware/sketch/issues/93

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s1ff4cfd325e3822ck
diff --git a/webui/src/web-components/sketch-chat-input.ts b/webui/src/web-components/sketch-chat-input.ts
index 581c975..69dd44c 100644
--- a/webui/src/web-components/sketch-chat-input.ts
+++ b/webui/src/web-components/sketch-chat-input.ts
@@ -6,6 +6,9 @@
   @state()
   content: string = "";
 
+  @state()
+  isDraggingOver: 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
@@ -17,6 +20,7 @@
       background: #f0f0f0;
       padding: 15px;
       min-height: 40px; /* Ensure minimum height */
+      position: relative;
     }
 
     .chat-input-wrapper {
@@ -57,11 +61,40 @@
     #sendChatButton:hover {
       background-color: #0d8bf2;
     }
+
+    /* 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 {
+      background-color: #ffffff;
+      padding: 15px 20px;
+      border-radius: 4px;
+      font-weight: 600;
+      box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+    }
   `;
 
   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() {
@@ -69,6 +102,66 @@
     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
+    const loadingText = `[Uploading ${file.name}...]`;
+    this.content = `${textBefore}${loadingText}${textAfter}`;
+
+    // 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}]`);
+
+      // Adjust the cursor position after the inserted text
+      requestAnimationFrame(() => {
+        this.adjustChatSpacing();
+        this.chatInput.focus();
+        const newPos = textBefore.length + data.path.length + 2; // +2 for the brackets
+        this.chatInput.selectionStart = newPos;
+        this.chatInput.selectionEnd = newPos;
+      });
+
+      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;
+    }
+  }
+
   // Handle paste events for files (including images)
   private _handlePaste = async (event: ClipboardEvent) => {
     // Check if the clipboard contains files
@@ -78,58 +171,59 @@
       // Handle the file upload (for any file type, not just images)
       event.preventDefault(); // Prevent default paste behavior
 
-      // Create a FormData object to send the file
-      const formData = new FormData();
-      formData.append("file", file);
-
-      // Insert a placeholder at the current cursor position
+      // Get the current cursor position
       const cursorPos = this.chatInput.selectionStart;
-      const textBefore = this.content.substring(0, cursorPos);
-      const textAfter = this.content.substring(cursorPos);
+      await this._uploadFile(file, cursorPos);
+    }
+  };
 
-      // Add a loading indicator
-      const loadingText = `[Uploading ${file.name}...]`;
-      this.content = `${textBefore}${loadingText}${textAfter}`;
+  // Handle drag events for file drop operation
+  private _handleDragOver(event: DragEvent) {
+    event.preventDefault(); // Necessary to allow dropping
+    event.stopPropagation();
+  }
 
-      // Adjust spacing immediately to show loading indicator
-      requestAnimationFrame(() => this.adjustChatSpacing());
+  private _handleDragEnter(event: DragEvent) {
+    event.preventDefault();
+    event.stopPropagation();
+    this.isDraggingOver = true;
+  }
 
-      try {
-        // Upload the file to the server using a relative path
-        const response = await fetch("./upload", {
-          method: "POST",
-          body: formData,
-        });
+  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;
+    }
+  }
 
-        if (!response.ok) {
-          throw new Error(`Upload failed: ${response.statusText}`);
+  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
         }
-
-        const data = await response.json();
-
-        // Replace the loading placeholder with the actual file path
-        this.content = this.content.replace(loadingText, `[${data.path}]`);
-
-        // Adjust the cursor position after the inserted text
-        requestAnimationFrame(() => {
-          this.adjustChatSpacing();
-          this.chatInput.focus();
-          const newPos = textBefore.length + data.path.length + 2; // +2 for the brackets
-          this.chatInput.selectionStart = newPos;
-          this.chatInput.selectionEnd = newPos;
-        });
-      } 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();
-        });
       }
     }
   };
@@ -149,6 +243,20 @@
   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() {
@@ -208,6 +316,15 @@
 
       // 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
@@ -238,6 +355,13 @@
             Send
           </button>
         </div>
+        ${this.isDraggingOver
+          ? html`
+              <div class="drop-zone-overlay">
+                <div class="drop-zone-message">Drop files here</div>
+              </div>
+            `
+          : ""}
       </div>
     `;
   }