blob: a1b948fef9e70599451021d35c6b3268988b3304 [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
535 private getLanguageForFile(filename: string): string {
536 const extension = filename.split(".").pop()?.toLowerCase() || "";
537 const langMap: Record<string, string> = {
538 js: "javascript",
539 ts: "typescript",
540 py: "python",
541 html: "html",
542 css: "css",
543 json: "json",
544 md: "markdown",
545 go: "go",
546 };
Philip Zeyligerd85e97d2025-05-28 17:59:08 +0000547 return langMap[extension] || "plaintext";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700548 }
549
550 /**
551 * Update editor options
552 */
553 setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
554 if (this.editor) {
555 this.editor.updateOptions(value);
556 }
557 }
558
559 /**
560 * Toggle hideUnchangedRegions feature
561 */
562 toggleHideUnchangedRegions(enabled: boolean) {
563 if (this.editor) {
564 this.editor.updateOptions({
565 hideUnchangedRegions: {
566 enabled: enabled,
567 contextLineCount: 3,
568 minimumLineCount: 3,
569 revealLineCount: 10,
570 },
571 });
572 }
573 }
574
575 // Models for the editor
576 private originalModel?: monaco.editor.ITextModel;
577 private modifiedModel?: monaco.editor.ITextModel;
578
579 private initializeEditor() {
580 try {
581 // Disable semantic validation globally for TypeScript/JavaScript
582 monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
Autoformatter8c463622025-05-16 21:54:17 +0000583 noSemanticValidation: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700584 });
585 monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
Autoformatter8c463622025-05-16 21:54:17 +0000586 noSemanticValidation: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700587 });
Autoformatter8c463622025-05-16 21:54:17 +0000588
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700589 // First time initialization
590 if (!this.editor) {
591 // Create the diff editor only once
592 this.editor = monaco.editor.createDiffEditor(this.container.value!, {
593 automaticLayout: true,
594 // Make it read-only by default
Autoformatter8c463622025-05-16 21:54:17 +0000595 // We'll adjust individual editor settings after creation
596 readOnly: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700597 theme: "vs", // Always use light mode
598 renderSideBySide: true,
599 ignoreTrimWhitespace: false,
600 // Focus on the differences by hiding unchanged regions
601 hideUnchangedRegions: {
602 enabled: true, // Enable the feature
603 contextLineCount: 3, // Show 3 lines of context around each difference
604 minimumLineCount: 3, // Hide regions only when they're at least 3 lines
605 revealLineCount: 10, // Show 10 lines when expanding a hidden region
606 },
607 });
608
609 console.log("Monaco diff editor created");
610
611 // Set up selection change event listeners for both editors
612 this.setupSelectionChangeListeners();
613
614 // If this is an editable view, set the correct read-only state for each editor
615 if (this.editableRight) {
616 // Make sure the original editor is always read-only
617 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
618 // Make sure the modified editor is editable
619 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
620 }
621 }
622
623 // Create or update models
624 this.updateModels();
Autoformatter8c463622025-05-16 21:54:17 +0000625 // Set up content change listener
626 this.setupContentChangeListener();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700627
628 // Force layout recalculation after a short delay
629 // This ensures the editor renders properly, especially with single files
630 setTimeout(() => {
631 if (this.editor) {
632 this.editor.layout();
633 console.log("Monaco diff editor layout updated");
634 }
635 }, 50);
636
637 console.log("Monaco diff editor initialized");
638 } catch (error) {
639 console.error("Error initializing Monaco editor:", error);
640 }
641 }
642
643 /**
644 * Sets up event listeners for text selection in both editors.
645 * This enables showing the comment UI when users select text and
646 * manages the visibility of UI components based on user interactions.
647 */
648 private setupSelectionChangeListeners() {
649 try {
650 if (!this.editor) {
651 console.log("Editor not available for setting up listeners");
652 return;
653 }
654
655 // Get both original and modified editors
656 const originalEditor = this.editor.getOriginalEditor();
657 const modifiedEditor = this.editor.getModifiedEditor();
658
659 if (!originalEditor || !modifiedEditor) {
660 console.log("Original or modified editor not available");
661 return;
662 }
663
664 // Add selection change listener to original editor
665 originalEditor.onDidChangeCursorSelection((e) => {
666 this.handleSelectionChange(e, originalEditor, "original");
667 });
668
669 // Add selection change listener to modified editor
670 modifiedEditor.onDidChangeCursorSelection((e) => {
671 this.handleSelectionChange(e, modifiedEditor, "modified");
672 });
673
674 // Create a debounced function for mouse move handling
675 let mouseMoveTimeout: number | null = null;
676 const handleMouseMove = () => {
677 // Clear any existing timeout
678 if (mouseMoveTimeout) {
679 window.clearTimeout(mouseMoveTimeout);
680 }
681
682 // If there's text selected and we're not showing the comment box, keep indicator visible
683 if (this.selectedText && !this.showCommentBox) {
684 this.showCommentIndicator = true;
685 this.requestUpdate();
686 }
687
688 // Set a new timeout to hide the indicator after a delay
689 mouseMoveTimeout = window.setTimeout(() => {
690 // Only hide if we're not showing the comment box and not actively hovering
691 if (!this.showCommentBox && !this._isHovering) {
692 this.showCommentIndicator = false;
693 this.requestUpdate();
694 }
695 }, 2000); // Hide after 2 seconds of inactivity
696 };
697
698 // Add mouse move listeners with debouncing
699 originalEditor.onMouseMove(() => handleMouseMove());
700 modifiedEditor.onMouseMove(() => handleMouseMove());
701
702 // Track hover state over the indicator and comment box
703 this._isHovering = false;
704
705 // Use the global document click handler to detect clicks outside
706 this._documentClickHandler = (e: MouseEvent) => {
707 try {
708 const target = e.target as HTMLElement;
709 const isIndicator =
710 target.matches(".comment-indicator") ||
711 !!target.closest(".comment-indicator");
712 const isCommentBox =
713 target.matches(".comment-box") || !!target.closest(".comment-box");
714
715 // If click is outside our UI elements
716 if (!isIndicator && !isCommentBox) {
717 // If we're not showing the comment box, hide the indicator
718 if (!this.showCommentBox) {
719 this.showCommentIndicator = false;
720 this.requestUpdate();
721 }
722 }
723 } catch (error) {
724 console.error("Error in document click handler:", error);
725 }
726 };
727
728 // Add the document click listener
729 document.addEventListener("click", this._documentClickHandler);
730
731 console.log("Selection change listeners set up successfully");
732 } catch (error) {
733 console.error("Error setting up selection listeners:", error);
734 }
735 }
736
737 // Track mouse hover state
738 private _isHovering = false;
739
740 // Store document click handler for cleanup
741 private _documentClickHandler: ((e: MouseEvent) => void) | null = null;
742
743 /**
744 * Handle selection change events from either editor
745 */
746 private handleSelectionChange(
747 e: monaco.editor.ICursorSelectionChangedEvent,
748 editor: monaco.editor.IStandaloneCodeEditor,
749 editorType: "original" | "modified",
750 ) {
751 try {
752 // If we're not making a selection (just moving cursor), do nothing
753 if (e.selection.isEmpty()) {
754 // Don't hide indicator or box if already shown
755 if (!this.showCommentBox) {
756 this.selectedText = null;
757 this.selectionRange = null;
758 this.showCommentIndicator = false;
759 }
760 return;
761 }
762
763 // Get selected text
764 const model = editor.getModel();
765 if (!model) {
766 console.log("No model available for selection");
767 return;
768 }
769
770 // Make sure selection is within valid range
771 const lineCount = model.getLineCount();
772 if (
773 e.selection.startLineNumber > lineCount ||
774 e.selection.endLineNumber > lineCount
775 ) {
776 console.log("Selection out of bounds");
777 return;
778 }
779
780 // Store which editor is active
781 this.activeEditor = editorType;
782
783 // Store selection range
784 this.selectionRange = {
785 startLineNumber: e.selection.startLineNumber,
786 startColumn: e.selection.startColumn,
787 endLineNumber: e.selection.endLineNumber,
788 endColumn: e.selection.endColumn,
789 };
790
791 try {
792 // Get the selected text
793 this.selectedText = model.getValueInRange(e.selection);
794 } catch (error) {
795 console.error("Error getting selected text:", error);
796 return;
797 }
798
799 // If there's selected text, show the indicator
800 if (this.selectedText && this.selectedText.trim() !== "") {
801 // Calculate indicator position safely
802 try {
803 // Use the editor's DOM node as positioning context
804 const editorDomNode = editor.getDomNode();
805 if (!editorDomNode) {
806 console.log("No editor DOM node available");
807 return;
808 }
809
810 // Get position from editor
811 const position = {
812 lineNumber: e.selection.endLineNumber,
813 column: e.selection.endColumn,
814 };
815
816 // Use editor's built-in method for coordinate conversion
817 const selectionCoords = editor.getScrolledVisiblePosition(position);
818
819 if (selectionCoords) {
820 // Get accurate DOM position for the selection end
821 const editorRect = editorDomNode.getBoundingClientRect();
822
823 // Calculate the actual screen position
824 const screenLeft = editorRect.left + selectionCoords.left;
825 const screenTop = editorRect.top + selectionCoords.top;
826
827 // Store absolute screen coordinates
828 this.indicatorPosition = {
829 top: screenTop,
830 left: screenLeft + 10, // Slight offset
831 };
832
833 // Check window boundaries to ensure the indicator stays visible
834 const viewportWidth = window.innerWidth;
835 const viewportHeight = window.innerHeight;
836
837 // Keep indicator within viewport bounds
838 if (this.indicatorPosition.left + 150 > viewportWidth) {
839 this.indicatorPosition.left = viewportWidth - 160;
840 }
841
842 if (this.indicatorPosition.top + 40 > viewportHeight) {
843 this.indicatorPosition.top = viewportHeight - 50;
844 }
845
846 // Show the indicator
847 this.showCommentIndicator = true;
848
849 // Request an update to ensure UI reflects changes
850 this.requestUpdate();
851 }
852 } catch (error) {
853 console.error("Error positioning comment indicator:", error);
854 }
855 }
856 } catch (error) {
857 console.error("Error handling selection change:", error);
858 }
859 }
860
861 /**
862 * Handle click on the comment indicator
863 */
864 private handleIndicatorClick(e: Event) {
865 try {
866 e.stopPropagation();
867 e.preventDefault();
868
869 this.showCommentBox = true;
870 this.commentText = ""; // Reset comment text
871
872 // Don't hide the indicator while comment box is shown
873 this.showCommentIndicator = true;
874
875 // Ensure UI updates
876 this.requestUpdate();
877 } catch (error) {
878 console.error("Error handling indicator click:", error);
879 }
880 }
881
882 /**
883 * Handle changes to the comment text
884 */
885 private handleCommentInput(e: Event) {
886 const target = e.target as HTMLTextAreaElement;
887 this.commentText = target.value;
888 }
889
890 /**
891 * Close the comment box
892 */
893 private closeCommentBox() {
894 this.showCommentBox = false;
895 // Also hide the indicator
896 this.showCommentIndicator = false;
897 }
898
899 /**
900 * Submit the comment
901 */
902 private submitComment() {
903 try {
904 if (!this.selectedText || !this.commentText) {
905 console.log("Missing selected text or comment");
906 return;
907 }
908
909 // Get the correct filename based on active editor
910 const fileContext =
911 this.activeEditor === "original"
912 ? this.originalFilename || "Original file"
913 : this.modifiedFilename || "Modified file";
914
915 // Include editor info to make it clear which version was commented on
916 const editorLabel =
917 this.activeEditor === "original" ? "[Original]" : "[Modified]";
918
919 // Format the comment in a readable way
920 const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}:\n${this.selectedText}\n\`\`\`\n\n${this.commentText}`;
921
922 // Close UI before dispatching to prevent interaction conflicts
923 this.closeCommentBox();
924
925 // Use setTimeout to ensure the UI has updated before sending the event
926 setTimeout(() => {
927 try {
928 // Dispatch a custom event with the comment details
929 const event = new CustomEvent("monaco-comment", {
930 detail: {
931 fileContext,
932 selectedText: this.selectedText,
933 commentText: this.commentText,
934 formattedComment,
935 selectionRange: this.selectionRange,
936 activeEditor: this.activeEditor,
937 },
938 bubbles: true,
939 composed: true,
940 });
941
942 this.dispatchEvent(event);
943 } catch (error) {
944 console.error("Error dispatching comment event:", error);
945 }
946 }, 0);
947 } catch (error) {
948 console.error("Error submitting comment:", error);
949 this.closeCommentBox();
950 }
951 }
952
953 private updateModels() {
954 try {
955 // Get language based on filename
956 const originalLang = this.getLanguageForFile(this.originalFilename || "");
957 const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
958
959 // Always create new models with unique URIs based on timestamp to avoid conflicts
960 const timestamp = new Date().getTime();
961 // TODO: Could put filename in these URIs; unclear how they're used right now.
962 const originalUri = monaco.Uri.parse(
963 `file:///original-${timestamp}.${originalLang}`,
964 );
965 const modifiedUri = monaco.Uri.parse(
966 `file:///modified-${timestamp}.${modifiedLang}`,
967 );
968
969 // Store references to old models
970 const oldOriginalModel = this.originalModel;
971 const oldModifiedModel = this.modifiedModel;
972
973 // Nullify instance variables to prevent accidental use
974 this.originalModel = undefined;
975 this.modifiedModel = undefined;
976
977 // Clear the editor model first to release Monaco's internal references
978 if (this.editor) {
979 this.editor.setModel(null);
980 }
981
982 // Now it's safe to dispose the old models
983 if (oldOriginalModel) {
984 oldOriginalModel.dispose();
985 }
986
987 if (oldModifiedModel) {
988 oldModifiedModel.dispose();
989 }
990
991 // Create new models
992 this.originalModel = monaco.editor.createModel(
993 this.originalCode || "",
994 originalLang,
995 originalUri,
996 );
997
998 this.modifiedModel = monaco.editor.createModel(
999 this.modifiedCode || "",
1000 modifiedLang,
1001 modifiedUri,
1002 );
1003
1004 // Set the new models on the editor
1005 if (this.editor) {
1006 this.editor.setModel({
1007 original: this.originalModel,
1008 modified: this.modifiedModel,
1009 });
1010 }
1011 this.setupContentChangeListener();
1012 } catch (error) {
1013 console.error("Error updating Monaco models:", error);
1014 }
1015 }
1016
1017 updated(changedProperties: Map<string, any>) {
1018 // If any relevant properties changed, just update the models
1019 if (
1020 changedProperties.has("originalCode") ||
1021 changedProperties.has("modifiedCode") ||
1022 changedProperties.has("originalFilename") ||
1023 changedProperties.has("modifiedFilename") ||
1024 changedProperties.has("editableRight")
1025 ) {
1026 if (this.editor) {
1027 this.updateModels();
1028
1029 // Force layout recalculation after model updates
1030 setTimeout(() => {
1031 if (this.editor) {
1032 this.editor.layout();
1033 }
1034 }, 50);
1035 } else {
1036 // If the editor isn't initialized yet but we received content,
1037 // initialize it now
1038 this.initializeEditor();
1039 }
1040 }
1041 }
1042
1043 // Add resize observer to ensure editor resizes when container changes
1044 firstUpdated() {
1045 // Initialize the editor
1046 this.initializeEditor();
1047
1048 // Create a ResizeObserver to monitor container size changes
1049 if (window.ResizeObserver) {
1050 const resizeObserver = new ResizeObserver(() => {
1051 if (this.editor) {
1052 this.editor.layout();
1053 }
1054 });
1055
1056 // Start observing the container
1057 if (this.container.value) {
1058 resizeObserver.observe(this.container.value);
1059 }
1060
1061 // Store the observer for cleanup
1062 this._resizeObserver = resizeObserver;
1063 }
1064
1065 // If editable, set up edit mode and content change listener
1066 if (this.editableRight && this.editor) {
1067 // Ensure the original editor is read-only
1068 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
1069 // Ensure the modified editor is editable
1070 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
1071 }
1072 }
1073
1074 private _resizeObserver: ResizeObserver | null = null;
1075
1076 disconnectedCallback() {
1077 super.disconnectedCallback();
1078
1079 try {
1080 // Clean up resources when element is removed
1081 if (this.editor) {
1082 this.editor.dispose();
1083 this.editor = undefined;
1084 }
1085
1086 // Dispose models to prevent memory leaks
1087 if (this.originalModel) {
1088 this.originalModel.dispose();
1089 this.originalModel = undefined;
1090 }
1091
1092 if (this.modifiedModel) {
1093 this.modifiedModel.dispose();
1094 this.modifiedModel = undefined;
1095 }
1096
1097 // Clean up resize observer
1098 if (this._resizeObserver) {
1099 this._resizeObserver.disconnect();
1100 this._resizeObserver = null;
1101 }
1102
1103 // Remove document click handler if set
1104 if (this._documentClickHandler) {
1105 document.removeEventListener("click", this._documentClickHandler);
1106 this._documentClickHandler = null;
1107 }
1108 } catch (error) {
1109 console.error("Error in disconnectedCallback:", error);
1110 }
1111 }
1112
1113 // disconnectedCallback implementation is defined below
1114}
1115
1116declare global {
1117 interface HTMLElementTagNameMap {
1118 "sketch-monaco-view": CodeDiffEditor;
1119 }
1120}