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