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