blob: 96671558541a7299bbd4ee33a7f4ccf01fb5601c [file] [log] [blame]
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._handleTodoComment = this._handleTodoComment.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);
window.addEventListener("todo-comment", this._handleTodoComment);
}
// 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());
}
private _handleTodoComment(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);
window.removeEventListener("todo-comment", this._handleTodoComment);
// 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;
}
}