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