blob: 1ffb7e26a8e0ea84b7437c397d96b78820cb33b0 [file] [log] [blame]
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
3import { createRef, Ref, ref } from "lit/directives/ref.js";
4
5// See https://rodydavis.com/posts/lit-monaco-editor for some ideas.
6
7import * as monaco from "monaco-editor";
8
9// Configure Monaco to use local workers with correct relative paths
10
11// Define Monaco CSS styles as a string constant
12const monacoStyles = `
13 /* Import Monaco editor styles */
14 @import url('./static/monaco/min/vs/editor/editor.main.css');
15
16 /* Codicon font is now defined globally in sketch-app-shell.css */
17
18 /* Custom Monaco styles */
19 .monaco-editor {
20 width: 100%;
21 height: 100%;
22 }
23
24 /* Custom font stack - ensure we have good monospace fonts */
25 .monaco-editor .view-lines,
26 .monaco-editor .view-line,
27 .monaco-editor-pane,
28 .monaco-editor .inputarea {
29 font-family: "Menlo", "Monaco", "Consolas", "Courier New", monospace !important;
30 font-size: 13px !important;
31 font-feature-settings: "liga" 0, "calt" 0 !important;
32 line-height: 1.5 !important;
33 }
34
35 /* Ensure light theme colors */
36 .monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input {
37 background-color: var(--monaco-editor-bg, #ffffff) !important;
38 }
39
40 .monaco-editor .margin {
41 background-color: var(--monaco-editor-margin, #f5f5f5) !important;
42 }
43`;
44
45// Configure Monaco to use local workers with correct relative paths
46// Monaco looks for this global configuration to determine how to load web workers
47// @ts-ignore - MonacoEnvironment is added to the global scope at runtime
48self.MonacoEnvironment = {
49 getWorkerUrl: function (_moduleId, label) {
50 if (label === "json") {
51 return "./static/json.worker.js";
52 }
53 if (label === "css" || label === "scss" || label === "less") {
54 return "./static/css.worker.js";
55 }
56 if (label === "html" || label === "handlebars" || label === "razor") {
57 return "./static/html.worker.js";
58 }
59 if (label === "typescript" || label === "javascript") {
60 return "./static/ts.worker.js";
61 }
62 return "./static/editor.worker.js";
63 },
64};
65
66@customElement("sketch-monaco-view")
67export class CodeDiffEditor extends LitElement {
68 // Editable state
69 @property({ type: Boolean, attribute: "editable-right" })
70 editableRight?: boolean;
71 private container: Ref<HTMLElement> = createRef();
72 editor?: monaco.editor.IStandaloneDiffEditor;
Philip Zeyliger272a90e2025-05-16 14:49:51 -070073
74 // Save state properties
75 @state() private saveState: "idle" | "modified" | "saving" | "saved" = "idle";
76 @state() private debounceSaveTimeout: number | null = null;
77 @state() private lastSavedContent: string = "";
78 @property() originalCode?: string = "// Original code here";
79 @property() modifiedCode?: string = "// Modified code here";
80 @property() originalFilename?: string = "original.js";
81 @property() modifiedFilename?: string = "modified.js";
82
83 /* Selected text and indicators */
84 @state()
85 private selectedText: string | null = null;
86
87 @state()
88 private selectionRange: {
89 startLineNumber: number;
90 startColumn: number;
91 endLineNumber: number;
92 endColumn: number;
93 } | null = null;
94
95 @state()
96 private showCommentIndicator: boolean = false;
97
98 @state()
99 private indicatorPosition: { top: number; left: number } = {
100 top: 0,
101 left: 0,
102 };
103
104 @state()
105 private showCommentBox: boolean = false;
106
107 @state()
108 private commentText: string = "";
109
110 @state()
111 private activeEditor: "original" | "modified" = "modified"; // Track which editor is active
112
113 // Custom event to request save action from external components
114 private requestSave() {
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000115 if (!this.editableRight || this.saveState !== "modified") return;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700116
117 this.saveState = "saving";
118
119 // Get current content from modified editor
120 const modifiedContent = this.modifiedModel?.getValue() || "";
121
122 // Create and dispatch the save event
123 const saveEvent = new CustomEvent("monaco-save", {
124 detail: {
125 path: this.modifiedFilename,
126 content: modifiedContent,
127 },
128 bubbles: true,
129 composed: true,
130 });
131
132 this.dispatchEvent(saveEvent);
133 }
134
135 // Method to be called from parent when save is complete
136 public notifySaveComplete(success: boolean) {
137 if (success) {
138 this.saveState = "saved";
139 // Update last saved content
140 this.lastSavedContent = this.modifiedModel?.getValue() || "";
141 // Reset to idle after a delay
142 setTimeout(() => {
143 this.saveState = "idle";
144 }, 2000);
145 } else {
146 // Return to modified state on error
147 this.saveState = "modified";
148 }
149 }
150
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000151 // Rescue people with strong save-constantly habits
152 private setupKeyboardShortcuts() {
153 if (!this.editor) return;
154 const modifiedEditor = this.editor.getModifiedEditor();
155 if (!modifiedEditor) return;
156
157 modifiedEditor.addCommand(
158 monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
159 () => {
160 this.requestSave();
Autoformatter57893c22025-05-29 13:49:53 +0000161 },
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000162 );
163
164 console.log("Keyboard shortcuts set up for Monaco editor");
165 }
166
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700167 // Setup content change listener for debounced save
168 private setupContentChangeListener() {
169 if (!this.editor || !this.editableRight) return;
170
171 const modifiedEditor = this.editor.getModifiedEditor();
172 if (!modifiedEditor || !modifiedEditor.getModel()) return;
173
174 // Store initial content
175 this.lastSavedContent = modifiedEditor.getModel()!.getValue();
176
177 // Listen for content changes
178 modifiedEditor.getModel()!.onDidChangeContent(() => {
179 const currentContent = modifiedEditor.getModel()!.getValue();
180
181 // Check if content has actually changed from last saved state
182 if (currentContent !== this.lastSavedContent) {
183 this.saveState = "modified";
184
185 // Debounce save request
186 if (this.debounceSaveTimeout) {
187 window.clearTimeout(this.debounceSaveTimeout);
188 }
189
190 this.debounceSaveTimeout = window.setTimeout(() => {
191 this.requestSave();
192 this.debounceSaveTimeout = null;
193 }, 1000); // 1 second debounce
194 }
195 });
196 }
197
198 static styles = css`
199 /* Save indicator styles */
200 .save-indicator {
201 position: absolute;
202 top: 4px;
203 right: 4px;
204 padding: 3px 8px;
205 border-radius: 3px;
206 font-size: 12px;
207 font-family: system-ui, sans-serif;
208 color: white;
209 z-index: 100;
210 opacity: 0.9;
211 pointer-events: none;
212 transition: opacity 0.3s ease;
213 }
214
Philip Zeyligere89b3082025-05-29 03:16:06 +0000215 .save-indicator.idle {
216 background-color: #6c757d;
217 }
218
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700219 .save-indicator.modified {
220 background-color: #f0ad4e;
221 }
222
223 .save-indicator.saving {
224 background-color: #5bc0de;
225 }
226
227 .save-indicator.saved {
228 background-color: #5cb85c;
229 }
230
231 /* Editor host styles */
232 :host {
233 --editor-width: 100%;
234 --editor-height: 100%;
235 display: flex;
236 flex: 1;
237 min-height: 0; /* Critical for flex layout */
238 position: relative; /* Establish positioning context */
239 height: 100%; /* Take full height */
240 width: 100%; /* Take full width */
241 }
242 main {
243 width: var(--editor-width);
244 height: var(--editor-height);
245 border: 1px solid #e0e0e0;
246 flex: 1;
247 min-height: 300px; /* Ensure a minimum height for the editor */
248 position: absolute; /* Absolute positioning to take full space */
249 top: 0;
250 left: 0;
251 right: 0;
252 bottom: 0;
253 }
254
255 /* Comment indicator and box styles */
256 .comment-indicator {
257 position: fixed;
258 background-color: rgba(66, 133, 244, 0.9);
259 color: white;
260 border-radius: 3px;
261 padding: 3px 8px;
262 font-size: 12px;
263 cursor: pointer;
264 box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
265 z-index: 10000;
266 animation: fadeIn 0.2s ease-in-out;
267 display: flex;
268 align-items: center;
269 gap: 4px;
270 pointer-events: all;
271 }
272
273 .comment-indicator:hover {
274 background-color: rgba(66, 133, 244, 1);
275 }
276
277 .comment-indicator span {
278 line-height: 1;
279 }
280
281 .comment-box {
282 position: fixed;
283 background-color: white;
284 border: 1px solid #ddd;
285 border-radius: 4px;
286 box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
287 padding: 12px;
288 z-index: 10001;
289 width: 350px;
290 animation: fadeIn 0.2s ease-in-out;
291 max-height: 80vh;
292 overflow-y: auto;
293 }
294
295 .comment-box-header {
296 display: flex;
297 justify-content: space-between;
298 align-items: center;
299 margin-bottom: 8px;
300 }
301
302 .comment-box-header h3 {
303 margin: 0;
304 font-size: 14px;
305 font-weight: 500;
306 }
307
308 .close-button {
309 background: none;
310 border: none;
311 cursor: pointer;
312 font-size: 16px;
313 color: #666;
314 padding: 2px 6px;
315 }
316
317 .close-button:hover {
318 color: #333;
319 }
320
321 .selected-text-preview {
322 background-color: #f5f5f5;
323 border: 1px solid #eee;
324 border-radius: 3px;
325 padding: 8px;
326 margin-bottom: 10px;
327 font-family: monospace;
328 font-size: 12px;
329 max-height: 80px;
330 overflow-y: auto;
331 white-space: pre-wrap;
332 word-break: break-all;
333 }
334
335 .comment-textarea {
336 width: 100%;
337 min-height: 80px;
338 padding: 8px;
339 border: 1px solid #ddd;
340 border-radius: 3px;
341 resize: vertical;
342 font-family: inherit;
343 margin-bottom: 10px;
344 box-sizing: border-box;
345 }
346
347 .comment-actions {
348 display: flex;
349 justify-content: flex-end;
350 gap: 8px;
351 }
352
353 .comment-actions button {
354 padding: 6px 12px;
355 border-radius: 3px;
356 cursor: pointer;
357 font-size: 12px;
358 }
359
360 .cancel-button {
361 background-color: transparent;
362 border: 1px solid #ddd;
363 }
364
365 .cancel-button:hover {
366 background-color: #f5f5f5;
367 }
368
369 .submit-button {
370 background-color: #4285f4;
371 color: white;
372 border: none;
373 }
374
375 .submit-button:hover {
376 background-color: #3367d6;
377 }
378
379 @keyframes fadeIn {
380 from {
381 opacity: 0;
382 }
383 to {
384 opacity: 1;
385 }
386 }
387 `;
388
389 render() {
390 return html`
391 <style>
392 ${monacoStyles}
393 </style>
394 <main ${ref(this.container)}></main>
395
396 <!-- Save indicator - shown when editing -->
Philip Zeyligere89b3082025-05-29 03:16:06 +0000397 ${this.editableRight
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700398 ? html`
399 <div class="save-indicator ${this.saveState}">
Philip Zeyligere89b3082025-05-29 03:16:06 +0000400 ${this.saveState === "idle"
401 ? "Editable"
402 : this.saveState === "modified"
403 ? "Modified..."
404 : this.saveState === "saving"
405 ? "Saving..."
406 : this.saveState === "saved"
407 ? "Saved"
408 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700409 </div>
410 `
411 : ""}
412
413 <!-- Comment indicator - shown when text is selected -->
414 ${this.showCommentIndicator
415 ? html`
416 <div
417 class="comment-indicator"
418 style="top: ${this.indicatorPosition.top}px; left: ${this
419 .indicatorPosition.left}px;"
420 @click="${this.handleIndicatorClick}"
421 @mouseenter="${() => {
422 this._isHovering = true;
423 }}"
424 @mouseleave="${() => {
425 this._isHovering = false;
426 }}"
427 >
428 <span>💬</span>
429 <span>Add comment</span>
430 </div>
431 `
432 : ""}
433
434 <!-- Comment box - shown when indicator is clicked -->
435 ${this.showCommentBox
436 ? html`
437 <div
438 class="comment-box"
439 style="${this.calculateCommentBoxPosition()}"
440 @mouseenter="${() => {
441 this._isHovering = true;
442 }}"
443 @mouseleave="${() => {
444 this._isHovering = false;
445 }}"
446 >
447 <div class="comment-box-header">
448 <h3>Add comment</h3>
449 <button class="close-button" @click="${this.closeCommentBox}">
450 ×
451 </button>
452 </div>
453 <div class="selected-text-preview">${this.selectedText}</div>
454 <textarea
455 class="comment-textarea"
456 placeholder="Type your comment here..."
457 .value="${this.commentText}"
458 @input="${this.handleCommentInput}"
459 ></textarea>
460 <div class="comment-actions">
461 <button class="cancel-button" @click="${this.closeCommentBox}">
462 Cancel
463 </button>
464 <button class="submit-button" @click="${this.submitComment}">
Josh Bleecher Snyderafeafea2025-05-23 20:27:39 +0000465 Add
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700466 </button>
467 </div>
468 </div>
469 `
470 : ""}
471 `;
472 }
473
474 /**
475 * Calculate the optimal position for the comment box to keep it in view
476 */
477 private calculateCommentBoxPosition(): string {
478 // Get viewport dimensions
479 const viewportWidth = window.innerWidth;
480 const viewportHeight = window.innerHeight;
481
482 // Default position (below indicator)
483 let top = this.indicatorPosition.top + 30;
484 let left = this.indicatorPosition.left;
485
486 // Estimated box dimensions
487 const boxWidth = 350;
488 const boxHeight = 300;
489
490 // Check if box would go off the right edge
491 if (left + boxWidth > viewportWidth) {
492 left = viewportWidth - boxWidth - 20; // Keep 20px margin
493 }
494
495 // Check if box would go off the bottom
496 const bottomSpace = viewportHeight - top;
497 if (bottomSpace < boxHeight) {
498 // Not enough space below, try to position above if possible
499 if (this.indicatorPosition.top > boxHeight) {
500 // Position above the indicator
501 top = this.indicatorPosition.top - boxHeight - 10;
502 } else {
503 // Not enough space above either, position at top of viewport with margin
504 top = 10;
505 }
506 }
507
508 // Ensure box is never positioned off-screen
509 top = Math.max(10, top);
510 left = Math.max(10, left);
511
512 return `top: ${top}px; left: ${left}px;`;
513 }
514
515 setOriginalCode(code: string, filename?: string) {
516 this.originalCode = code;
517 if (filename) {
518 this.originalFilename = filename;
519 }
520
521 // Update the model if the editor is initialized
522 if (this.editor) {
523 const model = this.editor.getOriginalEditor().getModel();
524 if (model) {
525 model.setValue(code);
526 if (filename) {
527 monaco.editor.setModelLanguage(
528 model,
529 this.getLanguageForFile(filename),
530 );
531 }
532 }
533 }
534 }
535
536 setModifiedCode(code: string, filename?: string) {
537 this.modifiedCode = code;
538 if (filename) {
539 this.modifiedFilename = filename;
540 }
541
542 // Update the model if the editor is initialized
543 if (this.editor) {
544 const model = this.editor.getModifiedEditor().getModel();
545 if (model) {
546 model.setValue(code);
547 if (filename) {
548 monaco.editor.setModelLanguage(
549 model,
550 this.getLanguageForFile(filename),
551 );
552 }
553 }
554 }
555 }
556
Philip Zeyliger70273072025-05-28 18:26:14 +0000557 private _extensionToLanguageMap: Map<string, string> | null = null;
558
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700559 private getLanguageForFile(filename: string): string {
Philip Zeyliger70273072025-05-28 18:26:14 +0000560 // Get the file extension (including the dot for exact matching)
561 const extension = "." + (filename.split(".").pop()?.toLowerCase() || "");
562
563 // Build the extension-to-language map on first use
564 if (!this._extensionToLanguageMap) {
565 this._extensionToLanguageMap = new Map();
566 const languages = monaco.languages.getLanguages();
567
568 for (const language of languages) {
569 if (language.extensions) {
570 for (const ext of language.extensions) {
571 // Monaco extensions already include the dot, so use them directly
572 this._extensionToLanguageMap.set(ext.toLowerCase(), language.id);
573 }
574 }
575 }
576 }
577
578 return this._extensionToLanguageMap.get(extension) || "plaintext";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700579 }
580
581 /**
582 * Update editor options
583 */
584 setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
585 if (this.editor) {
586 this.editor.updateOptions(value);
587 }
588 }
589
590 /**
591 * Toggle hideUnchangedRegions feature
592 */
593 toggleHideUnchangedRegions(enabled: boolean) {
594 if (this.editor) {
595 this.editor.updateOptions({
596 hideUnchangedRegions: {
597 enabled: enabled,
598 contextLineCount: 3,
599 minimumLineCount: 3,
600 revealLineCount: 10,
601 },
602 });
603 }
604 }
605
606 // Models for the editor
607 private originalModel?: monaco.editor.ITextModel;
608 private modifiedModel?: monaco.editor.ITextModel;
609
610 private initializeEditor() {
611 try {
612 // Disable semantic validation globally for TypeScript/JavaScript
613 monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
Autoformatter8c463622025-05-16 21:54:17 +0000614 noSemanticValidation: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700615 });
616 monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
Autoformatter8c463622025-05-16 21:54:17 +0000617 noSemanticValidation: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700618 });
Autoformatter8c463622025-05-16 21:54:17 +0000619
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700620 // First time initialization
621 if (!this.editor) {
622 // Create the diff editor only once
623 this.editor = monaco.editor.createDiffEditor(this.container.value!, {
624 automaticLayout: true,
625 // Make it read-only by default
Autoformatter8c463622025-05-16 21:54:17 +0000626 // We'll adjust individual editor settings after creation
627 readOnly: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700628 theme: "vs", // Always use light mode
629 renderSideBySide: true,
630 ignoreTrimWhitespace: false,
631 // Focus on the differences by hiding unchanged regions
632 hideUnchangedRegions: {
633 enabled: true, // Enable the feature
634 contextLineCount: 3, // Show 3 lines of context around each difference
635 minimumLineCount: 3, // Hide regions only when they're at least 3 lines
636 revealLineCount: 10, // Show 10 lines when expanding a hidden region
637 },
638 });
639
640 console.log("Monaco diff editor created");
641
642 // Set up selection change event listeners for both editors
643 this.setupSelectionChangeListeners();
644
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000645 this.setupKeyboardShortcuts();
646
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700647 // If this is an editable view, set the correct read-only state for each editor
648 if (this.editableRight) {
649 // Make sure the original editor is always read-only
650 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
651 // Make sure the modified editor is editable
652 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
653 }
654 }
655
656 // Create or update models
657 this.updateModels();
Autoformatter8c463622025-05-16 21:54:17 +0000658 // Set up content change listener
659 this.setupContentChangeListener();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700660
661 // Force layout recalculation after a short delay
662 // This ensures the editor renders properly, especially with single files
663 setTimeout(() => {
664 if (this.editor) {
665 this.editor.layout();
666 console.log("Monaco diff editor layout updated");
667 }
668 }, 50);
669
670 console.log("Monaco diff editor initialized");
671 } catch (error) {
672 console.error("Error initializing Monaco editor:", error);
673 }
674 }
675
676 /**
677 * Sets up event listeners for text selection in both editors.
678 * This enables showing the comment UI when users select text and
679 * manages the visibility of UI components based on user interactions.
680 */
681 private setupSelectionChangeListeners() {
682 try {
683 if (!this.editor) {
684 console.log("Editor not available for setting up listeners");
685 return;
686 }
687
688 // Get both original and modified editors
689 const originalEditor = this.editor.getOriginalEditor();
690 const modifiedEditor = this.editor.getModifiedEditor();
691
692 if (!originalEditor || !modifiedEditor) {
693 console.log("Original or modified editor not available");
694 return;
695 }
696
697 // Add selection change listener to original editor
698 originalEditor.onDidChangeCursorSelection((e) => {
699 this.handleSelectionChange(e, originalEditor, "original");
700 });
701
702 // Add selection change listener to modified editor
703 modifiedEditor.onDidChangeCursorSelection((e) => {
704 this.handleSelectionChange(e, modifiedEditor, "modified");
705 });
706
707 // Create a debounced function for mouse move handling
708 let mouseMoveTimeout: number | null = null;
709 const handleMouseMove = () => {
710 // Clear any existing timeout
711 if (mouseMoveTimeout) {
712 window.clearTimeout(mouseMoveTimeout);
713 }
714
715 // If there's text selected and we're not showing the comment box, keep indicator visible
716 if (this.selectedText && !this.showCommentBox) {
717 this.showCommentIndicator = true;
718 this.requestUpdate();
719 }
720
721 // Set a new timeout to hide the indicator after a delay
722 mouseMoveTimeout = window.setTimeout(() => {
723 // Only hide if we're not showing the comment box and not actively hovering
724 if (!this.showCommentBox && !this._isHovering) {
725 this.showCommentIndicator = false;
726 this.requestUpdate();
727 }
728 }, 2000); // Hide after 2 seconds of inactivity
729 };
730
731 // Add mouse move listeners with debouncing
732 originalEditor.onMouseMove(() => handleMouseMove());
733 modifiedEditor.onMouseMove(() => handleMouseMove());
734
735 // Track hover state over the indicator and comment box
736 this._isHovering = false;
737
738 // Use the global document click handler to detect clicks outside
739 this._documentClickHandler = (e: MouseEvent) => {
740 try {
741 const target = e.target as HTMLElement;
742 const isIndicator =
743 target.matches(".comment-indicator") ||
744 !!target.closest(".comment-indicator");
745 const isCommentBox =
746 target.matches(".comment-box") || !!target.closest(".comment-box");
747
748 // If click is outside our UI elements
749 if (!isIndicator && !isCommentBox) {
750 // If we're not showing the comment box, hide the indicator
751 if (!this.showCommentBox) {
752 this.showCommentIndicator = false;
753 this.requestUpdate();
754 }
755 }
756 } catch (error) {
757 console.error("Error in document click handler:", error);
758 }
759 };
760
761 // Add the document click listener
762 document.addEventListener("click", this._documentClickHandler);
763
764 console.log("Selection change listeners set up successfully");
765 } catch (error) {
766 console.error("Error setting up selection listeners:", error);
767 }
768 }
769
770 // Track mouse hover state
771 private _isHovering = false;
772
773 // Store document click handler for cleanup
774 private _documentClickHandler: ((e: MouseEvent) => void) | null = null;
775
776 /**
777 * Handle selection change events from either editor
778 */
779 private handleSelectionChange(
780 e: monaco.editor.ICursorSelectionChangedEvent,
781 editor: monaco.editor.IStandaloneCodeEditor,
782 editorType: "original" | "modified",
783 ) {
784 try {
785 // If we're not making a selection (just moving cursor), do nothing
786 if (e.selection.isEmpty()) {
787 // Don't hide indicator or box if already shown
788 if (!this.showCommentBox) {
789 this.selectedText = null;
790 this.selectionRange = null;
791 this.showCommentIndicator = false;
792 }
793 return;
794 }
795
796 // Get selected text
797 const model = editor.getModel();
798 if (!model) {
799 console.log("No model available for selection");
800 return;
801 }
802
803 // Make sure selection is within valid range
804 const lineCount = model.getLineCount();
805 if (
806 e.selection.startLineNumber > lineCount ||
807 e.selection.endLineNumber > lineCount
808 ) {
809 console.log("Selection out of bounds");
810 return;
811 }
812
813 // Store which editor is active
814 this.activeEditor = editorType;
815
816 // Store selection range
817 this.selectionRange = {
818 startLineNumber: e.selection.startLineNumber,
819 startColumn: e.selection.startColumn,
820 endLineNumber: e.selection.endLineNumber,
821 endColumn: e.selection.endColumn,
822 };
823
824 try {
Josh Bleecher Snyder444f7f02025-05-28 21:16:55 +0000825 // Expand selection to full lines for better context
826 const expandedSelection = {
827 startLineNumber: e.selection.startLineNumber,
828 startColumn: 1, // Start at beginning of line
829 endLineNumber: e.selection.endLineNumber,
830 endColumn: model.getLineMaxColumn(e.selection.endLineNumber), // End at end of line
831 };
832
833 // Get the selected text using the expanded selection
834 this.selectedText = model.getValueInRange(expandedSelection);
Autoformatter7ad1c7a2025-05-29 02:00:19 +0000835
Josh Bleecher Snyder444f7f02025-05-28 21:16:55 +0000836 // Update the selection range to reflect the full lines
837 this.selectionRange = {
838 startLineNumber: expandedSelection.startLineNumber,
839 startColumn: expandedSelection.startColumn,
840 endLineNumber: expandedSelection.endLineNumber,
841 endColumn: expandedSelection.endColumn,
842 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700843 } catch (error) {
844 console.error("Error getting selected text:", error);
845 return;
846 }
847
848 // If there's selected text, show the indicator
849 if (this.selectedText && this.selectedText.trim() !== "") {
850 // Calculate indicator position safely
851 try {
852 // Use the editor's DOM node as positioning context
853 const editorDomNode = editor.getDomNode();
854 if (!editorDomNode) {
855 console.log("No editor DOM node available");
856 return;
857 }
858
859 // Get position from editor
860 const position = {
861 lineNumber: e.selection.endLineNumber,
862 column: e.selection.endColumn,
863 };
864
865 // Use editor's built-in method for coordinate conversion
866 const selectionCoords = editor.getScrolledVisiblePosition(position);
867
868 if (selectionCoords) {
869 // Get accurate DOM position for the selection end
870 const editorRect = editorDomNode.getBoundingClientRect();
871
872 // Calculate the actual screen position
873 const screenLeft = editorRect.left + selectionCoords.left;
874 const screenTop = editorRect.top + selectionCoords.top;
875
876 // Store absolute screen coordinates
877 this.indicatorPosition = {
878 top: screenTop,
879 left: screenLeft + 10, // Slight offset
880 };
881
882 // Check window boundaries to ensure the indicator stays visible
883 const viewportWidth = window.innerWidth;
884 const viewportHeight = window.innerHeight;
885
886 // Keep indicator within viewport bounds
887 if (this.indicatorPosition.left + 150 > viewportWidth) {
888 this.indicatorPosition.left = viewportWidth - 160;
889 }
890
891 if (this.indicatorPosition.top + 40 > viewportHeight) {
892 this.indicatorPosition.top = viewportHeight - 50;
893 }
894
895 // Show the indicator
896 this.showCommentIndicator = true;
897
898 // Request an update to ensure UI reflects changes
899 this.requestUpdate();
900 }
901 } catch (error) {
902 console.error("Error positioning comment indicator:", error);
903 }
904 }
905 } catch (error) {
906 console.error("Error handling selection change:", error);
907 }
908 }
909
910 /**
911 * Handle click on the comment indicator
912 */
913 private handleIndicatorClick(e: Event) {
914 try {
915 e.stopPropagation();
916 e.preventDefault();
917
918 this.showCommentBox = true;
919 this.commentText = ""; // Reset comment text
920
921 // Don't hide the indicator while comment box is shown
922 this.showCommentIndicator = true;
923
924 // Ensure UI updates
925 this.requestUpdate();
926 } catch (error) {
927 console.error("Error handling indicator click:", error);
928 }
929 }
930
931 /**
932 * Handle changes to the comment text
933 */
934 private handleCommentInput(e: Event) {
935 const target = e.target as HTMLTextAreaElement;
936 this.commentText = target.value;
937 }
938
939 /**
940 * Close the comment box
941 */
942 private closeCommentBox() {
943 this.showCommentBox = false;
944 // Also hide the indicator
945 this.showCommentIndicator = false;
946 }
947
948 /**
949 * Submit the comment
950 */
951 private submitComment() {
952 try {
953 if (!this.selectedText || !this.commentText) {
954 console.log("Missing selected text or comment");
955 return;
956 }
957
958 // Get the correct filename based on active editor
959 const fileContext =
960 this.activeEditor === "original"
961 ? this.originalFilename || "Original file"
962 : this.modifiedFilename || "Modified file";
963
964 // Include editor info to make it clear which version was commented on
965 const editorLabel =
966 this.activeEditor === "original" ? "[Original]" : "[Modified]";
967
Josh Bleecher Snyderb34b8b32025-05-28 21:00:56 +0000968 // Add line number information if available
969 let lineInfo = "";
970 if (this.selectionRange) {
971 const startLine = this.selectionRange.startLineNumber;
972 const endLine = this.selectionRange.endLineNumber;
973 if (startLine === endLine) {
974 lineInfo = ` (line ${startLine})`;
975 } else {
976 lineInfo = ` (lines ${startLine}-${endLine})`;
977 }
978 }
979
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700980 // Format the comment in a readable way
Josh Bleecher Snyderb34b8b32025-05-28 21:00:56 +0000981 const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}${lineInfo}:\n${this.selectedText}\n\`\`\`\n\n${this.commentText}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700982
983 // Close UI before dispatching to prevent interaction conflicts
984 this.closeCommentBox();
985
986 // Use setTimeout to ensure the UI has updated before sending the event
987 setTimeout(() => {
988 try {
989 // Dispatch a custom event with the comment details
990 const event = new CustomEvent("monaco-comment", {
991 detail: {
992 fileContext,
993 selectedText: this.selectedText,
994 commentText: this.commentText,
995 formattedComment,
996 selectionRange: this.selectionRange,
997 activeEditor: this.activeEditor,
998 },
999 bubbles: true,
1000 composed: true,
1001 });
1002
1003 this.dispatchEvent(event);
1004 } catch (error) {
1005 console.error("Error dispatching comment event:", error);
1006 }
1007 }, 0);
1008 } catch (error) {
1009 console.error("Error submitting comment:", error);
1010 this.closeCommentBox();
1011 }
1012 }
1013
1014 private updateModels() {
1015 try {
1016 // Get language based on filename
1017 const originalLang = this.getLanguageForFile(this.originalFilename || "");
1018 const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
1019
1020 // Always create new models with unique URIs based on timestamp to avoid conflicts
1021 const timestamp = new Date().getTime();
1022 // TODO: Could put filename in these URIs; unclear how they're used right now.
1023 const originalUri = monaco.Uri.parse(
1024 `file:///original-${timestamp}.${originalLang}`,
1025 );
1026 const modifiedUri = monaco.Uri.parse(
1027 `file:///modified-${timestamp}.${modifiedLang}`,
1028 );
1029
1030 // Store references to old models
1031 const oldOriginalModel = this.originalModel;
1032 const oldModifiedModel = this.modifiedModel;
1033
1034 // Nullify instance variables to prevent accidental use
1035 this.originalModel = undefined;
1036 this.modifiedModel = undefined;
1037
1038 // Clear the editor model first to release Monaco's internal references
1039 if (this.editor) {
1040 this.editor.setModel(null);
1041 }
1042
1043 // Now it's safe to dispose the old models
1044 if (oldOriginalModel) {
1045 oldOriginalModel.dispose();
1046 }
1047
1048 if (oldModifiedModel) {
1049 oldModifiedModel.dispose();
1050 }
1051
1052 // Create new models
1053 this.originalModel = monaco.editor.createModel(
1054 this.originalCode || "",
1055 originalLang,
1056 originalUri,
1057 );
1058
1059 this.modifiedModel = monaco.editor.createModel(
1060 this.modifiedCode || "",
1061 modifiedLang,
1062 modifiedUri,
1063 );
1064
1065 // Set the new models on the editor
1066 if (this.editor) {
1067 this.editor.setModel({
1068 original: this.originalModel,
1069 modified: this.modifiedModel,
1070 });
1071 }
1072 this.setupContentChangeListener();
1073 } catch (error) {
1074 console.error("Error updating Monaco models:", error);
1075 }
1076 }
1077
1078 updated(changedProperties: Map<string, any>) {
1079 // If any relevant properties changed, just update the models
1080 if (
1081 changedProperties.has("originalCode") ||
1082 changedProperties.has("modifiedCode") ||
1083 changedProperties.has("originalFilename") ||
1084 changedProperties.has("modifiedFilename") ||
1085 changedProperties.has("editableRight")
1086 ) {
1087 if (this.editor) {
1088 this.updateModels();
1089
1090 // Force layout recalculation after model updates
1091 setTimeout(() => {
1092 if (this.editor) {
1093 this.editor.layout();
1094 }
1095 }, 50);
1096 } else {
1097 // If the editor isn't initialized yet but we received content,
1098 // initialize it now
1099 this.initializeEditor();
1100 }
1101 }
1102 }
1103
1104 // Add resize observer to ensure editor resizes when container changes
1105 firstUpdated() {
1106 // Initialize the editor
1107 this.initializeEditor();
1108
1109 // Create a ResizeObserver to monitor container size changes
1110 if (window.ResizeObserver) {
1111 const resizeObserver = new ResizeObserver(() => {
1112 if (this.editor) {
1113 this.editor.layout();
1114 }
1115 });
1116
1117 // Start observing the container
1118 if (this.container.value) {
1119 resizeObserver.observe(this.container.value);
1120 }
1121
1122 // Store the observer for cleanup
1123 this._resizeObserver = resizeObserver;
1124 }
1125
1126 // If editable, set up edit mode and content change listener
1127 if (this.editableRight && this.editor) {
1128 // Ensure the original editor is read-only
1129 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
1130 // Ensure the modified editor is editable
1131 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
1132 }
1133 }
1134
1135 private _resizeObserver: ResizeObserver | null = null;
1136
1137 disconnectedCallback() {
1138 super.disconnectedCallback();
1139
1140 try {
1141 // Clean up resources when element is removed
1142 if (this.editor) {
1143 this.editor.dispose();
1144 this.editor = undefined;
1145 }
1146
1147 // Dispose models to prevent memory leaks
1148 if (this.originalModel) {
1149 this.originalModel.dispose();
1150 this.originalModel = undefined;
1151 }
1152
1153 if (this.modifiedModel) {
1154 this.modifiedModel.dispose();
1155 this.modifiedModel = undefined;
1156 }
1157
1158 // Clean up resize observer
1159 if (this._resizeObserver) {
1160 this._resizeObserver.disconnect();
1161 this._resizeObserver = null;
1162 }
1163
1164 // Remove document click handler if set
1165 if (this._documentClickHandler) {
1166 document.removeEventListener("click", this._documentClickHandler);
1167 this._documentClickHandler = null;
1168 }
1169 } catch (error) {
1170 console.error("Error in disconnectedCallback:", error);
1171 }
1172 }
1173
1174 // disconnectedCallback implementation is defined below
1175}
1176
1177declare global {
1178 interface HTMLElementTagNameMap {
1179 "sketch-monaco-view": CodeDiffEditor;
1180 }
1181}