blob: 581c975779e1f896694ea99beeb5bf4ad73108f9 [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
9 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
10 // Note that these styles only apply to the scope of this web component's
11 // shadow DOM node, so they won't leak out or collide with CSS declared in
12 // other components or the containing web page (...unless you want it to do that).
13 static styles = css`
14 /* Chat styles - exactly matching timeline.css */
15 .chat-container {
Sean McCullough86b56862025-04-18 13:04:03 -070016 width: 100%;
17 background: #f0f0f0;
18 padding: 15px;
Sean McCullough86b56862025-04-18 13:04:03 -070019 min-height: 40px; /* Ensure minimum height */
20 }
21
22 .chat-input-wrapper {
23 display: flex;
24 max-width: 1200px;
25 margin: 0 auto;
26 gap: 10px;
27 }
28
29 #chatInput {
30 flex: 1;
31 padding: 12px;
32 border: 1px solid #ddd;
33 border-radius: 4px;
Sean McCullough07b3e392025-04-21 22:51:14 +000034 resize: vertical;
Sean McCullough86b56862025-04-18 13:04:03 -070035 font-family: monospace;
36 font-size: 12px;
37 min-height: 40px;
Sean McCullough07b3e392025-04-21 22:51:14 +000038 max-height: 300px;
Sean McCullough86b56862025-04-18 13:04:03 -070039 background: #f7f7f7;
Sean McCullough5164eee2025-04-21 18:20:23 -070040 overflow-y: auto;
Sean McCullough07b3e392025-04-21 22:51:14 +000041 box-sizing: border-box; /* Ensure padding is included in height calculation */
42 line-height: 1.4; /* Consistent line height for better height calculation */
Sean McCullough86b56862025-04-18 13:04:03 -070043 }
44
45 #sendChatButton {
46 background-color: #2196f3;
47 color: white;
48 border: none;
49 border-radius: 4px;
50 padding: 0 20px;
51 cursor: pointer;
52 font-weight: 600;
Pokey Rule97188fc2025-04-23 15:50:04 +010053 align-self: center;
54 height: 40px;
Sean McCullough86b56862025-04-18 13:04:03 -070055 }
56
57 #sendChatButton:hover {
58 background-color: #0d8bf2;
59 }
60 `;
61
62 constructor() {
63 super();
Philip Zeyliger73db6052025-04-23 13:09:07 -070064 this._handleDiffComment = this._handleDiffComment.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -070065 }
66
Sean McCullough86b56862025-04-18 13:04:03 -070067 connectedCallback() {
68 super.connectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -070069 window.addEventListener("diff-comment", this._handleDiffComment);
70 }
71
Philip Zeyligerf84e88c2025-05-14 23:19:01 +000072 // Handle paste events for files (including images)
73 private _handlePaste = async (event: ClipboardEvent) => {
74 // Check if the clipboard contains files
75 if (event.clipboardData && event.clipboardData.files.length > 0) {
76 const file = event.clipboardData.files[0];
77
78 // Handle the file upload (for any file type, not just images)
79 event.preventDefault(); // Prevent default paste behavior
80
81 // Create a FormData object to send the file
82 const formData = new FormData();
83 formData.append("file", file);
84
85 // Insert a placeholder at the current cursor position
86 const cursorPos = this.chatInput.selectionStart;
87 const textBefore = this.content.substring(0, cursorPos);
88 const textAfter = this.content.substring(cursorPos);
89
90 // Add a loading indicator
91 const loadingText = `[Uploading ${file.name}...]`;
92 this.content = `${textBefore}${loadingText}${textAfter}`;
93
94 // Adjust spacing immediately to show loading indicator
95 requestAnimationFrame(() => this.adjustChatSpacing());
96
97 try {
98 // Upload the file to the server using a relative path
99 const response = await fetch("./upload", {
100 method: "POST",
101 body: formData,
102 });
103
104 if (!response.ok) {
105 throw new Error(`Upload failed: ${response.statusText}`);
106 }
107
108 const data = await response.json();
109
110 // Replace the loading placeholder with the actual file path
111 this.content = this.content.replace(loadingText, `[${data.path}]`);
112
113 // Adjust the cursor position after the inserted text
114 requestAnimationFrame(() => {
115 this.adjustChatSpacing();
116 this.chatInput.focus();
117 const newPos = textBefore.length + data.path.length + 2; // +2 for the brackets
118 this.chatInput.selectionStart = newPos;
119 this.chatInput.selectionEnd = newPos;
120 });
121 } catch (error) {
122 console.error("Failed to upload file:", error);
123
124 // Replace loading indicator with error message
125 const errorText = `[Upload failed: ${error.message}]`;
126 this.content = this.content.replace(loadingText, errorText);
127
128 // Adjust spacing to show error message
129 requestAnimationFrame(() => {
130 this.adjustChatSpacing();
131 this.chatInput.focus();
132 });
133 }
134 }
135 };
136
Philip Zeyliger73db6052025-04-23 13:09:07 -0700137 private _handleDiffComment(event: CustomEvent) {
138 const { comment } = event.detail;
139 if (!comment) return;
140
141 if (this.content != "") {
142 this.content += "\n\n";
143 }
144 this.content += comment;
145 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700146 }
147
148 // See https://lit.dev/docs/components/lifecycle/
149 disconnectedCallback() {
150 super.disconnectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700151 window.removeEventListener("diff-comment", this._handleDiffComment);
Sean McCullough86b56862025-04-18 13:04:03 -0700152 }
153
154 sendChatMessage() {
155 const event = new CustomEvent("send-chat", {
156 detail: { message: this.content },
157 bubbles: true,
158 composed: true,
159 });
160 this.dispatchEvent(event);
Philip Zeyliger73db6052025-04-23 13:09:07 -0700161
162 // TODO(philip?): Ideally we only clear the content if the send is successful.
163 this.content = ""; // Clear content after sending
Sean McCullough86b56862025-04-18 13:04:03 -0700164 }
165
166 adjustChatSpacing() {
Sean McCullough07b3e392025-04-21 22:51:14 +0000167 if (!this.chatInput) return;
Sean McCullough5164eee2025-04-21 18:20:23 -0700168
Sean McCullough07b3e392025-04-21 22:51:14 +0000169 // Reset height to minimal value to correctly calculate scrollHeight
Sean McCullough5164eee2025-04-21 18:20:23 -0700170 this.chatInput.style.height = "auto";
171
Sean McCullough07b3e392025-04-21 22:51:14 +0000172 // Get the scroll height (content height)
173 const scrollHeight = this.chatInput.scrollHeight;
Sean McCullough5164eee2025-04-21 18:20:23 -0700174
Sean McCullough07b3e392025-04-21 22:51:14 +0000175 // Set the height to match content (up to max-height which is handled by CSS)
176 this.chatInput.style.height = `${scrollHeight}px`;
Sean McCullough86b56862025-04-18 13:04:03 -0700177 }
178
Philip Zeyliger73db6052025-04-23 13:09:07 -0700179 async _sendChatClicked() {
Sean McCullough86b56862025-04-18 13:04:03 -0700180 this.sendChatMessage();
181 this.chatInput.focus(); // Refocus the input after sending
Sean McCullough07b3e392025-04-21 22:51:14 +0000182 // Reset height after sending a message
183 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700184 }
185
186 _chatInputKeyDown(event: KeyboardEvent) {
187 // Send message if Enter is pressed without Shift key
188 if (event.key === "Enter" && !event.shiftKey) {
189 event.preventDefault(); // Prevent default newline
190 this.sendChatMessage();
191 }
192 }
193
194 _chatInputChanged(event) {
195 this.content = event.target.value;
Sean McCullough07b3e392025-04-21 22:51:14 +0000196 // Use requestAnimationFrame to ensure DOM updates have completed
Sean McCullough86b56862025-04-18 13:04:03 -0700197 requestAnimationFrame(() => this.adjustChatSpacing());
198 }
199
200 @query("#chatInput")
Sean McCullough07b3e392025-04-21 22:51:14 +0000201 chatInput: HTMLTextAreaElement;
Sean McCullough86b56862025-04-18 13:04:03 -0700202
203 protected firstUpdated(): void {
204 if (this.chatInput) {
205 this.chatInput.focus();
Sean McCullough07b3e392025-04-21 22:51:14 +0000206 // Initialize the input height
207 this.adjustChatSpacing();
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000208
209 // Add paste event listener for image handling
210 this.chatInput.addEventListener("paste", this._handlePaste);
Sean McCullough86b56862025-04-18 13:04:03 -0700211 }
Josh Bleecher Snydere2c7f722025-05-01 21:58:41 +0000212
213 // Add window.onload handler to ensure the input is focused when the page fully loads
214 window.addEventListener(
215 "load",
216 () => {
217 if (this.chatInput) {
218 this.chatInput.focus();
219 }
220 },
221 { once: true },
222 );
Sean McCullough86b56862025-04-18 13:04:03 -0700223 }
224
225 render() {
226 return html`
227 <div class="chat-container">
228 <div class="chat-input-wrapper">
229 <textarea
230 id="chatInput"
231 placeholder="Type your message here and press Enter to send..."
232 autofocus
233 @keydown="${this._chatInputKeyDown}"
234 @input="${this._chatInputChanged}"
235 .value=${this.content || ""}
236 ></textarea>
237 <button @click="${this._sendChatClicked}" id="sendChatButton">
238 Send
239 </button>
240 </div>
241 </div>
242 `;
243 }
244}
245
246declare global {
247 interface HTMLElementTagNameMap {
248 "sketch-chat-input": SketchChatInput;
249 }
250}