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