blob: 6e783fa95e374a79efe793e80cb535e24a9542c0 [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
philip.zeyligerc0a44592025-06-15 21:24:57 -07007import type * as monaco from "monaco-editor";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07008
philip.zeyligerc0a44592025-06-15 21:24:57 -07009// Monaco is loaded dynamically - see loadMonaco() function
10declare global {
11 interface Window {
12 monaco?: typeof monaco;
13 }
14}
15
16// Monaco hash will be injected at build time
17declare const __MONACO_HASH__: string;
18
19// Load Monaco editor dynamically
20let monacoLoadPromise: Promise<any> | null = null;
21
22function loadMonaco(): Promise<typeof monaco> {
23 if (monacoLoadPromise) {
24 return monacoLoadPromise;
25 }
26
27 if (window.monaco) {
28 return Promise.resolve(window.monaco);
29 }
30
31 monacoLoadPromise = new Promise((resolve, reject) => {
32 // Get the Monaco hash from build-time constant
33 const monacoHash = __MONACO_HASH__;
Autoformatter2f8464c2025-06-16 04:27:05 +000034
philip.zeyligerc0a44592025-06-15 21:24:57 -070035 // Try to load the external Monaco bundle
Autoformatter2f8464c2025-06-16 04:27:05 +000036 const script = document.createElement("script");
philip.zeyligerc0a44592025-06-15 21:24:57 -070037 script.onload = () => {
38 // The Monaco bundle should set window.monaco
39 if (window.monaco) {
40 resolve(window.monaco);
41 } else {
Autoformatter2f8464c2025-06-16 04:27:05 +000042 reject(new Error("Monaco not loaded from external bundle"));
philip.zeyligerc0a44592025-06-15 21:24:57 -070043 }
44 };
45 script.onerror = (error) => {
Autoformatter2f8464c2025-06-16 04:27:05 +000046 console.warn("Failed to load external Monaco bundle:", error);
47 reject(new Error("Monaco external bundle failed to load"));
philip.zeyligerc0a44592025-06-15 21:24:57 -070048 };
Autoformatter2f8464c2025-06-16 04:27:05 +000049
philip.zeyligerc0a44592025-06-15 21:24:57 -070050 // Don't set type="module" since we're using IIFE format
51 script.src = `./static/monaco-standalone-${monacoHash}.js`;
52 document.head.appendChild(script);
53 });
54
55 return monacoLoadPromise;
56}
Philip Zeyliger272a90e2025-05-16 14:49:51 -070057
58// Define Monaco CSS styles as a string constant
59const monacoStyles = `
60 /* Import Monaco editor styles */
61 @import url('./static/monaco/min/vs/editor/editor.main.css');
62
63 /* Codicon font is now defined globally in sketch-app-shell.css */
64
65 /* Custom Monaco styles */
66 .monaco-editor {
67 width: 100%;
68 height: 100%;
69 }
70
philip.zeyliger7351cd92025-06-14 12:25:31 -070071 // /* Custom font stack - ensure we have good monospace fonts */
72 // .monaco-editor .view-lines,
73 // .monaco-editor .view-line,
74 // .monaco-editor-pane,
75 // .monaco-editor .inputarea {
76 // font-family: "Menlo", "Monaco", "Consolas", "Courier New", monospace !important;
77 // font-size: 13px !important;
78 // font-feature-settings: "liga" 0, "calt" 0 !important;
79 // line-height: 1.5 !important;
80 // }
Philip Zeyliger272a90e2025-05-16 14:49:51 -070081
82 /* Ensure light theme colors */
83 .monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input {
84 background-color: var(--monaco-editor-bg, #ffffff) !important;
85 }
86
87 .monaco-editor .margin {
88 background-color: var(--monaco-editor-margin, #f5f5f5) !important;
89 }
David Crawshawe2954ce2025-06-15 00:06:34 +000090
91 /* Hide all scrollbars completely */
92 .monaco-editor .scrollbar,
93 .monaco-editor .scroll-decoration,
94 .monaco-editor .invisible.scrollbar,
95 .monaco-editor .slider,
96 .monaco-editor .vertical.scrollbar,
97 .monaco-editor .horizontal.scrollbar {
98 display: none !important;
99 visibility: hidden !important;
100 width: 0 !important;
101 height: 0 !important;
102 }
103
104 /* Ensure content area takes full width/height without scrollbar space */
105 .monaco-editor .monaco-scrollable-element {
106 /* Remove any padding/margin that might be reserved for scrollbars */
107 padding-right: 0 !important;
108 padding-bottom: 0 !important;
109 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700110`;
111
112// Configure Monaco to use local workers with correct relative paths
113// Monaco looks for this global configuration to determine how to load web workers
114// @ts-ignore - MonacoEnvironment is added to the global scope at runtime
115self.MonacoEnvironment = {
116 getWorkerUrl: function (_moduleId, label) {
117 if (label === "json") {
118 return "./static/json.worker.js";
119 }
120 if (label === "css" || label === "scss" || label === "less") {
121 return "./static/css.worker.js";
122 }
123 if (label === "html" || label === "handlebars" || label === "razor") {
124 return "./static/html.worker.js";
125 }
126 if (label === "typescript" || label === "javascript") {
127 return "./static/ts.worker.js";
128 }
129 return "./static/editor.worker.js";
130 },
131};
132
133@customElement("sketch-monaco-view")
134export class CodeDiffEditor extends LitElement {
135 // Editable state
136 @property({ type: Boolean, attribute: "editable-right" })
137 editableRight?: boolean;
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000138
139 // Inline diff mode (for mobile)
140 @property({ type: Boolean, attribute: "inline" })
141 inline?: boolean;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700142 private container: Ref<HTMLElement> = createRef();
143 editor?: monaco.editor.IStandaloneDiffEditor;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700144
145 // Save state properties
146 @state() private saveState: "idle" | "modified" | "saving" | "saved" = "idle";
147 @state() private debounceSaveTimeout: number | null = null;
148 @state() private lastSavedContent: string = "";
149 @property() originalCode?: string = "// Original code here";
150 @property() modifiedCode?: string = "// Modified code here";
151 @property() originalFilename?: string = "original.js";
152 @property() modifiedFilename?: string = "modified.js";
153
154 /* Selected text and indicators */
155 @state()
156 private selectedText: string | null = null;
157
158 @state()
159 private selectionRange: {
160 startLineNumber: number;
161 startColumn: number;
162 endLineNumber: number;
163 endColumn: number;
164 } | null = null;
165
166 @state()
167 private showCommentIndicator: boolean = false;
168
169 @state()
170 private indicatorPosition: { top: number; left: number } = {
171 top: 0,
172 left: 0,
173 };
174
175 @state()
176 private showCommentBox: boolean = false;
177
178 @state()
179 private commentText: string = "";
180
181 @state()
182 private activeEditor: "original" | "modified" = "modified"; // Track which editor is active
183
184 // Custom event to request save action from external components
185 private requestSave() {
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000186 if (!this.editableRight || this.saveState !== "modified") return;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700187
188 this.saveState = "saving";
189
190 // Get current content from modified editor
191 const modifiedContent = this.modifiedModel?.getValue() || "";
192
193 // Create and dispatch the save event
194 const saveEvent = new CustomEvent("monaco-save", {
195 detail: {
196 path: this.modifiedFilename,
197 content: modifiedContent,
198 },
199 bubbles: true,
200 composed: true,
201 });
202
203 this.dispatchEvent(saveEvent);
204 }
205
206 // Method to be called from parent when save is complete
207 public notifySaveComplete(success: boolean) {
208 if (success) {
209 this.saveState = "saved";
210 // Update last saved content
211 this.lastSavedContent = this.modifiedModel?.getValue() || "";
212 // Reset to idle after a delay
213 setTimeout(() => {
214 this.saveState = "idle";
215 }, 2000);
216 } else {
217 // Return to modified state on error
218 this.saveState = "modified";
219 }
220 }
221
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000222 // Rescue people with strong save-constantly habits
223 private setupKeyboardShortcuts() {
224 if (!this.editor) return;
225 const modifiedEditor = this.editor.getModifiedEditor();
226 if (!modifiedEditor) return;
227
philip.zeyligerc0a44592025-06-15 21:24:57 -0700228 const monaco = window.monaco;
229 if (!monaco) return;
Autoformatter2f8464c2025-06-16 04:27:05 +0000230
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000231 modifiedEditor.addCommand(
232 monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
233 () => {
234 this.requestSave();
Autoformatter57893c22025-05-29 13:49:53 +0000235 },
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000236 );
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000237 }
238
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700239 // Setup content change listener for debounced save
240 private setupContentChangeListener() {
241 if (!this.editor || !this.editableRight) return;
242
243 const modifiedEditor = this.editor.getModifiedEditor();
244 if (!modifiedEditor || !modifiedEditor.getModel()) return;
245
246 // Store initial content
247 this.lastSavedContent = modifiedEditor.getModel()!.getValue();
248
249 // Listen for content changes
250 modifiedEditor.getModel()!.onDidChangeContent(() => {
251 const currentContent = modifiedEditor.getModel()!.getValue();
252
253 // Check if content has actually changed from last saved state
254 if (currentContent !== this.lastSavedContent) {
255 this.saveState = "modified";
256
257 // Debounce save request
258 if (this.debounceSaveTimeout) {
259 window.clearTimeout(this.debounceSaveTimeout);
260 }
261
262 this.debounceSaveTimeout = window.setTimeout(() => {
263 this.requestSave();
264 this.debounceSaveTimeout = null;
265 }, 1000); // 1 second debounce
266 }
267 });
268 }
269
270 static styles = css`
271 /* Save indicator styles */
272 .save-indicator {
273 position: absolute;
274 top: 4px;
275 right: 4px;
276 padding: 3px 8px;
277 border-radius: 3px;
278 font-size: 12px;
279 font-family: system-ui, sans-serif;
280 color: white;
281 z-index: 100;
282 opacity: 0.9;
283 pointer-events: none;
284 transition: opacity 0.3s ease;
285 }
286
Philip Zeyligere89b3082025-05-29 03:16:06 +0000287 .save-indicator.idle {
288 background-color: #6c757d;
289 }
290
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700291 .save-indicator.modified {
292 background-color: #f0ad4e;
293 }
294
295 .save-indicator.saving {
296 background-color: #5bc0de;
297 }
298
299 .save-indicator.saved {
300 background-color: #5cb85c;
301 }
302
303 /* Editor host styles */
304 :host {
305 --editor-width: 100%;
306 --editor-height: 100%;
307 display: flex;
David Crawshaw26f3f342025-06-14 19:58:32 +0000308 flex: none; /* Don't grow/shrink - size is determined by content */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700309 min-height: 0; /* Critical for flex layout */
310 position: relative; /* Establish positioning context */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700311 width: 100%; /* Take full width */
David Crawshaw26f3f342025-06-14 19:58:32 +0000312 /* Height will be set dynamically by setupAutoSizing */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700313 }
314 main {
David Crawshaw26f3f342025-06-14 19:58:32 +0000315 width: 100%;
316 height: 100%; /* Fill the host element completely */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700317 border: 1px solid #e0e0e0;
David Crawshaw26f3f342025-06-14 19:58:32 +0000318 flex: none; /* Size determined by parent */
319 min-height: 200px; /* Ensure a minimum height for the editor */
320 /* Remove absolute positioning - use normal block layout */
321 position: relative;
322 display: block;
David Crawshawdba26b52025-06-15 00:33:45 +0000323 box-sizing: border-box; /* Include border in width calculation */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700324 }
325
326 /* Comment indicator and box styles */
327 .comment-indicator {
328 position: fixed;
329 background-color: rgba(66, 133, 244, 0.9);
330 color: white;
331 border-radius: 3px;
332 padding: 3px 8px;
333 font-size: 12px;
334 cursor: pointer;
335 box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
336 z-index: 10000;
337 animation: fadeIn 0.2s ease-in-out;
338 display: flex;
339 align-items: center;
340 gap: 4px;
341 pointer-events: all;
342 }
343
344 .comment-indicator:hover {
345 background-color: rgba(66, 133, 244, 1);
346 }
347
348 .comment-indicator span {
349 line-height: 1;
350 }
351
352 .comment-box {
353 position: fixed;
354 background-color: white;
355 border: 1px solid #ddd;
356 border-radius: 4px;
357 box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
358 padding: 12px;
359 z-index: 10001;
360 width: 350px;
361 animation: fadeIn 0.2s ease-in-out;
362 max-height: 80vh;
363 overflow-y: auto;
364 }
365
366 .comment-box-header {
367 display: flex;
368 justify-content: space-between;
369 align-items: center;
370 margin-bottom: 8px;
371 }
372
373 .comment-box-header h3 {
374 margin: 0;
375 font-size: 14px;
376 font-weight: 500;
377 }
378
379 .close-button {
380 background: none;
381 border: none;
382 cursor: pointer;
383 font-size: 16px;
384 color: #666;
385 padding: 2px 6px;
386 }
387
388 .close-button:hover {
389 color: #333;
390 }
391
392 .selected-text-preview {
393 background-color: #f5f5f5;
394 border: 1px solid #eee;
395 border-radius: 3px;
396 padding: 8px;
397 margin-bottom: 10px;
398 font-family: monospace;
399 font-size: 12px;
400 max-height: 80px;
401 overflow-y: auto;
402 white-space: pre-wrap;
403 word-break: break-all;
404 }
405
406 .comment-textarea {
407 width: 100%;
408 min-height: 80px;
409 padding: 8px;
410 border: 1px solid #ddd;
411 border-radius: 3px;
412 resize: vertical;
413 font-family: inherit;
414 margin-bottom: 10px;
415 box-sizing: border-box;
416 }
417
418 .comment-actions {
419 display: flex;
420 justify-content: flex-end;
421 gap: 8px;
422 }
423
424 .comment-actions button {
425 padding: 6px 12px;
426 border-radius: 3px;
427 cursor: pointer;
428 font-size: 12px;
429 }
430
431 .cancel-button {
432 background-color: transparent;
433 border: 1px solid #ddd;
434 }
435
436 .cancel-button:hover {
437 background-color: #f5f5f5;
438 }
439
440 .submit-button {
441 background-color: #4285f4;
442 color: white;
443 border: none;
444 }
445
446 .submit-button:hover {
447 background-color: #3367d6;
448 }
449
450 @keyframes fadeIn {
451 from {
452 opacity: 0;
453 }
454 to {
455 opacity: 1;
456 }
457 }
458 `;
459
460 render() {
461 return html`
462 <style>
463 ${monacoStyles}
464 </style>
465 <main ${ref(this.container)}></main>
466
467 <!-- Save indicator - shown when editing -->
Philip Zeyligere89b3082025-05-29 03:16:06 +0000468 ${this.editableRight
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700469 ? html`
470 <div class="save-indicator ${this.saveState}">
Philip Zeyligere89b3082025-05-29 03:16:06 +0000471 ${this.saveState === "idle"
472 ? "Editable"
473 : this.saveState === "modified"
474 ? "Modified..."
475 : this.saveState === "saving"
476 ? "Saving..."
477 : this.saveState === "saved"
478 ? "Saved"
479 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700480 </div>
481 `
482 : ""}
483
484 <!-- Comment indicator - shown when text is selected -->
485 ${this.showCommentIndicator
486 ? html`
487 <div
488 class="comment-indicator"
489 style="top: ${this.indicatorPosition.top}px; left: ${this
490 .indicatorPosition.left}px;"
491 @click="${this.handleIndicatorClick}"
492 @mouseenter="${() => {
493 this._isHovering = true;
494 }}"
495 @mouseleave="${() => {
496 this._isHovering = false;
497 }}"
498 >
499 <span>💬</span>
500 <span>Add comment</span>
501 </div>
502 `
503 : ""}
504
505 <!-- Comment box - shown when indicator is clicked -->
506 ${this.showCommentBox
507 ? html`
508 <div
509 class="comment-box"
510 style="${this.calculateCommentBoxPosition()}"
511 @mouseenter="${() => {
512 this._isHovering = true;
513 }}"
514 @mouseleave="${() => {
515 this._isHovering = false;
516 }}"
517 >
518 <div class="comment-box-header">
519 <h3>Add comment</h3>
520 <button class="close-button" @click="${this.closeCommentBox}">
521 ×
522 </button>
523 </div>
524 <div class="selected-text-preview">${this.selectedText}</div>
525 <textarea
526 class="comment-textarea"
527 placeholder="Type your comment here..."
528 .value="${this.commentText}"
529 @input="${this.handleCommentInput}"
530 ></textarea>
531 <div class="comment-actions">
532 <button class="cancel-button" @click="${this.closeCommentBox}">
533 Cancel
534 </button>
535 <button class="submit-button" @click="${this.submitComment}">
Josh Bleecher Snyderafeafea2025-05-23 20:27:39 +0000536 Add
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700537 </button>
538 </div>
539 </div>
540 `
541 : ""}
542 `;
543 }
544
545 /**
546 * Calculate the optimal position for the comment box to keep it in view
547 */
548 private calculateCommentBoxPosition(): string {
549 // Get viewport dimensions
550 const viewportWidth = window.innerWidth;
551 const viewportHeight = window.innerHeight;
552
553 // Default position (below indicator)
554 let top = this.indicatorPosition.top + 30;
555 let left = this.indicatorPosition.left;
556
557 // Estimated box dimensions
558 const boxWidth = 350;
559 const boxHeight = 300;
560
561 // Check if box would go off the right edge
562 if (left + boxWidth > viewportWidth) {
563 left = viewportWidth - boxWidth - 20; // Keep 20px margin
564 }
565
566 // Check if box would go off the bottom
567 const bottomSpace = viewportHeight - top;
568 if (bottomSpace < boxHeight) {
569 // Not enough space below, try to position above if possible
570 if (this.indicatorPosition.top > boxHeight) {
571 // Position above the indicator
572 top = this.indicatorPosition.top - boxHeight - 10;
573 } else {
574 // Not enough space above either, position at top of viewport with margin
575 top = 10;
576 }
577 }
578
579 // Ensure box is never positioned off-screen
580 top = Math.max(10, top);
581 left = Math.max(10, left);
582
583 return `top: ${top}px; left: ${left}px;`;
584 }
585
586 setOriginalCode(code: string, filename?: string) {
587 this.originalCode = code;
588 if (filename) {
589 this.originalFilename = filename;
590 }
591
592 // Update the model if the editor is initialized
593 if (this.editor) {
594 const model = this.editor.getOriginalEditor().getModel();
595 if (model) {
596 model.setValue(code);
597 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700598 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700599 model,
600 this.getLanguageForFile(filename),
601 );
602 }
603 }
604 }
605 }
606
607 setModifiedCode(code: string, filename?: string) {
608 this.modifiedCode = code;
609 if (filename) {
610 this.modifiedFilename = filename;
611 }
612
613 // Update the model if the editor is initialized
614 if (this.editor) {
615 const model = this.editor.getModifiedEditor().getModel();
616 if (model) {
617 model.setValue(code);
618 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700619 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700620 model,
621 this.getLanguageForFile(filename),
622 );
623 }
624 }
625 }
626 }
627
Philip Zeyliger70273072025-05-28 18:26:14 +0000628 private _extensionToLanguageMap: Map<string, string> | null = null;
629
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700630 private getLanguageForFile(filename: string): string {
Philip Zeyliger70273072025-05-28 18:26:14 +0000631 // Get the file extension (including the dot for exact matching)
632 const extension = "." + (filename.split(".").pop()?.toLowerCase() || "");
633
634 // Build the extension-to-language map on first use
635 if (!this._extensionToLanguageMap) {
636 this._extensionToLanguageMap = new Map();
philip.zeyligerc0a44592025-06-15 21:24:57 -0700637 const languages = window.monaco!.languages.getLanguages();
Philip Zeyliger70273072025-05-28 18:26:14 +0000638
639 for (const language of languages) {
640 if (language.extensions) {
641 for (const ext of language.extensions) {
642 // Monaco extensions already include the dot, so use them directly
643 this._extensionToLanguageMap.set(ext.toLowerCase(), language.id);
644 }
645 }
646 }
647 }
648
649 return this._extensionToLanguageMap.get(extension) || "plaintext";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700650 }
651
652 /**
653 * Update editor options
654 */
655 setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
656 if (this.editor) {
657 this.editor.updateOptions(value);
David Crawshaw26f3f342025-06-14 19:58:32 +0000658 // Re-fit content after options change
659 if (this.fitEditorToContent) {
660 setTimeout(() => this.fitEditorToContent!(), 50);
661 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700662 }
663 }
664
665 /**
666 * Toggle hideUnchangedRegions feature
667 */
668 toggleHideUnchangedRegions(enabled: boolean) {
669 if (this.editor) {
670 this.editor.updateOptions({
671 hideUnchangedRegions: {
672 enabled: enabled,
673 contextLineCount: 3,
674 minimumLineCount: 3,
675 revealLineCount: 10,
676 },
677 });
David Crawshaw26f3f342025-06-14 19:58:32 +0000678 // Re-fit content after toggling
679 if (this.fitEditorToContent) {
680 setTimeout(() => this.fitEditorToContent!(), 100);
681 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700682 }
683 }
684
685 // Models for the editor
686 private originalModel?: monaco.editor.ITextModel;
687 private modifiedModel?: monaco.editor.ITextModel;
688
philip.zeyligerc0a44592025-06-15 21:24:57 -0700689 private async initializeEditor() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700690 try {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700691 // Load Monaco dynamically
692 const monaco = await loadMonaco();
Autoformatter2f8464c2025-06-16 04:27:05 +0000693
philip.zeyliger7351cd92025-06-14 12:25:31 -0700694 // Disable semantic validation globally for TypeScript/JavaScript if available
695 if (monaco.languages && monaco.languages.typescript) {
696 monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
697 noSemanticValidation: true,
698 });
699 monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
700 noSemanticValidation: true,
701 });
702 }
Autoformatter8c463622025-05-16 21:54:17 +0000703
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700704 // First time initialization
705 if (!this.editor) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000706 // Create the diff editor with auto-sizing configuration
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700707 this.editor = monaco.editor.createDiffEditor(this.container.value!, {
David Crawshaw26f3f342025-06-14 19:58:32 +0000708 automaticLayout: false, // We'll resize manually
Autoformatter8c463622025-05-16 21:54:17 +0000709 readOnly: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700710 theme: "vs", // Always use light mode
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000711 renderSideBySide: !this.inline,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700712 ignoreTrimWhitespace: false,
David Crawshawf00c7b12025-06-15 00:24:46 +0000713 renderOverviewRuler: false, // Disable the overview ruler
David Crawshaw26f3f342025-06-14 19:58:32 +0000714 scrollbar: {
Autoformatter9abf8032025-06-14 23:24:08 +0000715 vertical: "hidden",
716 horizontal: "hidden",
David Crawshaw26f3f342025-06-14 19:58:32 +0000717 handleMouseWheel: false, // Let outer scroller eat the wheel
David Crawshawe2954ce2025-06-15 00:06:34 +0000718 useShadows: false, // Disable scrollbar shadows
719 verticalHasArrows: false, // Remove scrollbar arrows
720 horizontalHasArrows: false, // Remove scrollbar arrows
721 verticalScrollbarSize: 0, // Set scrollbar track width to 0
722 horizontalScrollbarSize: 0, // Set scrollbar track height to 0
David Crawshaw26f3f342025-06-14 19:58:32 +0000723 },
724 minimap: { enabled: false },
725 overviewRulerLanes: 0,
726 scrollBeyondLastLine: false,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700727 // Focus on the differences by hiding unchanged regions
728 hideUnchangedRegions: {
729 enabled: true, // Enable the feature
730 contextLineCount: 3, // Show 3 lines of context around each difference
731 minimumLineCount: 3, // Hide regions only when they're at least 3 lines
732 revealLineCount: 10, // Show 10 lines when expanding a hidden region
733 },
734 });
735
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700736 // Set up selection change event listeners for both editors
737 this.setupSelectionChangeListeners();
738
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000739 this.setupKeyboardShortcuts();
740
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700741 // If this is an editable view, set the correct read-only state for each editor
742 if (this.editableRight) {
743 // Make sure the original editor is always read-only
744 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
745 // Make sure the modified editor is editable
746 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
747 }
philip.zeyliger7351cd92025-06-14 12:25:31 -0700748
David Crawshaw26f3f342025-06-14 19:58:32 +0000749 // Set up auto-sizing
750 this.setupAutoSizing();
751
philip.zeyliger7351cd92025-06-14 12:25:31 -0700752 // Add Monaco editor to debug global
753 this.addToDebugGlobal();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700754 }
755
756 // Create or update models
757 this.updateModels();
Autoformatter8c463622025-05-16 21:54:17 +0000758 // Set up content change listener
759 this.setupContentChangeListener();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700760
philip.zeyliger7351cd92025-06-14 12:25:31 -0700761 // Fix cursor positioning issues by ensuring fonts are loaded
philip.zeyliger7351cd92025-06-14 12:25:31 -0700762 document.fonts.ready.then(() => {
763 if (this.editor) {
764 monaco.editor.remeasureFonts();
David Crawshaw26f3f342025-06-14 19:58:32 +0000765 this.fitEditorToContent();
philip.zeyliger7351cd92025-06-14 12:25:31 -0700766 }
767 });
768
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700769 // Force layout recalculation after a short delay
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700770 setTimeout(() => {
771 if (this.editor) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000772 this.fitEditorToContent();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700773 }
David Crawshaw26f3f342025-06-14 19:58:32 +0000774 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700775 } catch (error) {
776 console.error("Error initializing Monaco editor:", error);
777 }
778 }
779
780 /**
781 * Sets up event listeners for text selection in both editors.
782 * This enables showing the comment UI when users select text and
783 * manages the visibility of UI components based on user interactions.
784 */
785 private setupSelectionChangeListeners() {
786 try {
787 if (!this.editor) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700788 return;
789 }
790
791 // Get both original and modified editors
792 const originalEditor = this.editor.getOriginalEditor();
793 const modifiedEditor = this.editor.getModifiedEditor();
794
795 if (!originalEditor || !modifiedEditor) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700796 return;
797 }
798
799 // Add selection change listener to original editor
800 originalEditor.onDidChangeCursorSelection((e) => {
801 this.handleSelectionChange(e, originalEditor, "original");
802 });
803
804 // Add selection change listener to modified editor
805 modifiedEditor.onDidChangeCursorSelection((e) => {
806 this.handleSelectionChange(e, modifiedEditor, "modified");
807 });
808
809 // Create a debounced function for mouse move handling
810 let mouseMoveTimeout: number | null = null;
811 const handleMouseMove = () => {
812 // Clear any existing timeout
813 if (mouseMoveTimeout) {
814 window.clearTimeout(mouseMoveTimeout);
815 }
816
817 // If there's text selected and we're not showing the comment box, keep indicator visible
818 if (this.selectedText && !this.showCommentBox) {
819 this.showCommentIndicator = true;
820 this.requestUpdate();
821 }
822
823 // Set a new timeout to hide the indicator after a delay
824 mouseMoveTimeout = window.setTimeout(() => {
825 // Only hide if we're not showing the comment box and not actively hovering
826 if (!this.showCommentBox && !this._isHovering) {
827 this.showCommentIndicator = false;
828 this.requestUpdate();
829 }
830 }, 2000); // Hide after 2 seconds of inactivity
831 };
832
833 // Add mouse move listeners with debouncing
834 originalEditor.onMouseMove(() => handleMouseMove());
835 modifiedEditor.onMouseMove(() => handleMouseMove());
836
837 // Track hover state over the indicator and comment box
838 this._isHovering = false;
839
840 // Use the global document click handler to detect clicks outside
841 this._documentClickHandler = (e: MouseEvent) => {
842 try {
843 const target = e.target as HTMLElement;
844 const isIndicator =
845 target.matches(".comment-indicator") ||
846 !!target.closest(".comment-indicator");
847 const isCommentBox =
848 target.matches(".comment-box") || !!target.closest(".comment-box");
849
850 // If click is outside our UI elements
851 if (!isIndicator && !isCommentBox) {
852 // If we're not showing the comment box, hide the indicator
853 if (!this.showCommentBox) {
854 this.showCommentIndicator = false;
855 this.requestUpdate();
856 }
857 }
858 } catch (error) {
859 console.error("Error in document click handler:", error);
860 }
861 };
862
863 // Add the document click listener
864 document.addEventListener("click", this._documentClickHandler);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700865 } catch (error) {
866 console.error("Error setting up selection listeners:", error);
867 }
868 }
869
870 // Track mouse hover state
871 private _isHovering = false;
872
873 // Store document click handler for cleanup
874 private _documentClickHandler: ((e: MouseEvent) => void) | null = null;
875
876 /**
877 * Handle selection change events from either editor
878 */
879 private handleSelectionChange(
880 e: monaco.editor.ICursorSelectionChangedEvent,
881 editor: monaco.editor.IStandaloneCodeEditor,
882 editorType: "original" | "modified",
883 ) {
884 try {
885 // If we're not making a selection (just moving cursor), do nothing
886 if (e.selection.isEmpty()) {
887 // Don't hide indicator or box if already shown
888 if (!this.showCommentBox) {
889 this.selectedText = null;
890 this.selectionRange = null;
891 this.showCommentIndicator = false;
892 }
893 return;
894 }
895
896 // Get selected text
897 const model = editor.getModel();
898 if (!model) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700899 return;
900 }
901
902 // Make sure selection is within valid range
903 const lineCount = model.getLineCount();
904 if (
905 e.selection.startLineNumber > lineCount ||
906 e.selection.endLineNumber > lineCount
907 ) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700908 return;
909 }
910
911 // Store which editor is active
912 this.activeEditor = editorType;
913
914 // Store selection range
915 this.selectionRange = {
916 startLineNumber: e.selection.startLineNumber,
917 startColumn: e.selection.startColumn,
918 endLineNumber: e.selection.endLineNumber,
919 endColumn: e.selection.endColumn,
920 };
921
922 try {
Josh Bleecher Snyder444f7f02025-05-28 21:16:55 +0000923 // Expand selection to full lines for better context
924 const expandedSelection = {
925 startLineNumber: e.selection.startLineNumber,
926 startColumn: 1, // Start at beginning of line
927 endLineNumber: e.selection.endLineNumber,
928 endColumn: model.getLineMaxColumn(e.selection.endLineNumber), // End at end of line
929 };
930
931 // Get the selected text using the expanded selection
932 this.selectedText = model.getValueInRange(expandedSelection);
Autoformatter7ad1c7a2025-05-29 02:00:19 +0000933
Josh Bleecher Snyder444f7f02025-05-28 21:16:55 +0000934 // Update the selection range to reflect the full lines
935 this.selectionRange = {
936 startLineNumber: expandedSelection.startLineNumber,
937 startColumn: expandedSelection.startColumn,
938 endLineNumber: expandedSelection.endLineNumber,
939 endColumn: expandedSelection.endColumn,
940 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700941 } catch (error) {
942 console.error("Error getting selected text:", error);
943 return;
944 }
945
946 // If there's selected text, show the indicator
947 if (this.selectedText && this.selectedText.trim() !== "") {
948 // Calculate indicator position safely
949 try {
950 // Use the editor's DOM node as positioning context
951 const editorDomNode = editor.getDomNode();
952 if (!editorDomNode) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700953 return;
954 }
955
956 // Get position from editor
957 const position = {
958 lineNumber: e.selection.endLineNumber,
959 column: e.selection.endColumn,
960 };
961
962 // Use editor's built-in method for coordinate conversion
963 const selectionCoords = editor.getScrolledVisiblePosition(position);
964
965 if (selectionCoords) {
966 // Get accurate DOM position for the selection end
967 const editorRect = editorDomNode.getBoundingClientRect();
968
969 // Calculate the actual screen position
970 const screenLeft = editorRect.left + selectionCoords.left;
971 const screenTop = editorRect.top + selectionCoords.top;
972
973 // Store absolute screen coordinates
974 this.indicatorPosition = {
975 top: screenTop,
976 left: screenLeft + 10, // Slight offset
977 };
978
979 // Check window boundaries to ensure the indicator stays visible
980 const viewportWidth = window.innerWidth;
981 const viewportHeight = window.innerHeight;
982
983 // Keep indicator within viewport bounds
984 if (this.indicatorPosition.left + 150 > viewportWidth) {
985 this.indicatorPosition.left = viewportWidth - 160;
986 }
987
988 if (this.indicatorPosition.top + 40 > viewportHeight) {
989 this.indicatorPosition.top = viewportHeight - 50;
990 }
991
992 // Show the indicator
993 this.showCommentIndicator = true;
994
995 // Request an update to ensure UI reflects changes
996 this.requestUpdate();
997 }
998 } catch (error) {
999 console.error("Error positioning comment indicator:", error);
1000 }
1001 }
1002 } catch (error) {
1003 console.error("Error handling selection change:", error);
1004 }
1005 }
1006
1007 /**
1008 * Handle click on the comment indicator
1009 */
1010 private handleIndicatorClick(e: Event) {
1011 try {
1012 e.stopPropagation();
1013 e.preventDefault();
1014
1015 this.showCommentBox = true;
1016 this.commentText = ""; // Reset comment text
1017
1018 // Don't hide the indicator while comment box is shown
1019 this.showCommentIndicator = true;
1020
1021 // Ensure UI updates
1022 this.requestUpdate();
1023 } catch (error) {
1024 console.error("Error handling indicator click:", error);
1025 }
1026 }
1027
1028 /**
1029 * Handle changes to the comment text
1030 */
1031 private handleCommentInput(e: Event) {
1032 const target = e.target as HTMLTextAreaElement;
1033 this.commentText = target.value;
1034 }
1035
1036 /**
1037 * Close the comment box
1038 */
1039 private closeCommentBox() {
1040 this.showCommentBox = false;
1041 // Also hide the indicator
1042 this.showCommentIndicator = false;
1043 }
1044
1045 /**
1046 * Submit the comment
1047 */
1048 private submitComment() {
1049 try {
1050 if (!this.selectedText || !this.commentText) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001051 return;
1052 }
1053
1054 // Get the correct filename based on active editor
1055 const fileContext =
1056 this.activeEditor === "original"
1057 ? this.originalFilename || "Original file"
1058 : this.modifiedFilename || "Modified file";
1059
1060 // Include editor info to make it clear which version was commented on
1061 const editorLabel =
1062 this.activeEditor === "original" ? "[Original]" : "[Modified]";
1063
Josh Bleecher Snyderb34b8b32025-05-28 21:00:56 +00001064 // Add line number information if available
1065 let lineInfo = "";
1066 if (this.selectionRange) {
1067 const startLine = this.selectionRange.startLineNumber;
1068 const endLine = this.selectionRange.endLineNumber;
1069 if (startLine === endLine) {
1070 lineInfo = ` (line ${startLine})`;
1071 } else {
1072 lineInfo = ` (lines ${startLine}-${endLine})`;
1073 }
1074 }
1075
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001076 // Format the comment in a readable way
Josh Bleecher Snyderb34b8b32025-05-28 21:00:56 +00001077 const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}${lineInfo}:\n${this.selectedText}\n\`\`\`\n\n${this.commentText}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001078
1079 // Close UI before dispatching to prevent interaction conflicts
1080 this.closeCommentBox();
1081
1082 // Use setTimeout to ensure the UI has updated before sending the event
1083 setTimeout(() => {
1084 try {
1085 // Dispatch a custom event with the comment details
1086 const event = new CustomEvent("monaco-comment", {
1087 detail: {
1088 fileContext,
1089 selectedText: this.selectedText,
1090 commentText: this.commentText,
1091 formattedComment,
1092 selectionRange: this.selectionRange,
1093 activeEditor: this.activeEditor,
1094 },
1095 bubbles: true,
1096 composed: true,
1097 });
1098
1099 this.dispatchEvent(event);
1100 } catch (error) {
1101 console.error("Error dispatching comment event:", error);
1102 }
1103 }, 0);
1104 } catch (error) {
1105 console.error("Error submitting comment:", error);
1106 this.closeCommentBox();
1107 }
1108 }
1109
1110 private updateModels() {
1111 try {
1112 // Get language based on filename
1113 const originalLang = this.getLanguageForFile(this.originalFilename || "");
1114 const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
1115
1116 // Always create new models with unique URIs based on timestamp to avoid conflicts
1117 const timestamp = new Date().getTime();
1118 // TODO: Could put filename in these URIs; unclear how they're used right now.
philip.zeyligerc0a44592025-06-15 21:24:57 -07001119 const originalUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001120 `file:///original-${timestamp}.${originalLang}`,
1121 );
philip.zeyligerc0a44592025-06-15 21:24:57 -07001122 const modifiedUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001123 `file:///modified-${timestamp}.${modifiedLang}`,
1124 );
1125
1126 // Store references to old models
1127 const oldOriginalModel = this.originalModel;
1128 const oldModifiedModel = this.modifiedModel;
1129
1130 // Nullify instance variables to prevent accidental use
1131 this.originalModel = undefined;
1132 this.modifiedModel = undefined;
1133
1134 // Clear the editor model first to release Monaco's internal references
1135 if (this.editor) {
1136 this.editor.setModel(null);
1137 }
1138
1139 // Now it's safe to dispose the old models
1140 if (oldOriginalModel) {
1141 oldOriginalModel.dispose();
1142 }
1143
1144 if (oldModifiedModel) {
1145 oldModifiedModel.dispose();
1146 }
1147
1148 // Create new models
philip.zeyligerc0a44592025-06-15 21:24:57 -07001149 this.originalModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001150 this.originalCode || "",
1151 originalLang,
1152 originalUri,
1153 );
1154
philip.zeyligerc0a44592025-06-15 21:24:57 -07001155 this.modifiedModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001156 this.modifiedCode || "",
1157 modifiedLang,
1158 modifiedUri,
1159 );
1160
1161 // Set the new models on the editor
1162 if (this.editor) {
1163 this.editor.setModel({
1164 original: this.originalModel,
1165 modified: this.modifiedModel,
1166 });
Autoformatter9abf8032025-06-14 23:24:08 +00001167
David Crawshaw26f3f342025-06-14 19:58:32 +00001168 // Set initial hideUnchangedRegions state (default to enabled/collapsed)
1169 this.editor.updateOptions({
1170 hideUnchangedRegions: {
1171 enabled: true, // Default to collapsed state
1172 contextLineCount: 3,
1173 minimumLineCount: 3,
1174 revealLineCount: 10,
1175 },
1176 });
Autoformatter9abf8032025-06-14 23:24:08 +00001177
David Crawshaw26f3f342025-06-14 19:58:32 +00001178 // Fit content after setting new models
1179 if (this.fitEditorToContent) {
1180 setTimeout(() => this.fitEditorToContent!(), 50);
1181 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001182 }
1183 this.setupContentChangeListener();
1184 } catch (error) {
1185 console.error("Error updating Monaco models:", error);
1186 }
1187 }
1188
philip.zeyligerc0a44592025-06-15 21:24:57 -07001189 async updated(changedProperties: Map<string, any>) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001190 // If any relevant properties changed, just update the models
1191 if (
1192 changedProperties.has("originalCode") ||
1193 changedProperties.has("modifiedCode") ||
1194 changedProperties.has("originalFilename") ||
1195 changedProperties.has("modifiedFilename") ||
1196 changedProperties.has("editableRight")
1197 ) {
1198 if (this.editor) {
1199 this.updateModels();
1200
David Crawshaw26f3f342025-06-14 19:58:32 +00001201 // Force auto-sizing after model updates
1202 // Use a slightly longer delay to ensure layout is stable
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001203 setTimeout(() => {
David Crawshaw26f3f342025-06-14 19:58:32 +00001204 if (this.fitEditorToContent) {
1205 this.fitEditorToContent();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001206 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001207 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001208 } else {
1209 // If the editor isn't initialized yet but we received content,
1210 // initialize it now
philip.zeyligerc0a44592025-06-15 21:24:57 -07001211 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001212 }
1213 }
1214 }
1215
David Crawshaw26f3f342025-06-14 19:58:32 +00001216 // Set up auto-sizing for multi-file diff view
1217 private setupAutoSizing() {
1218 if (!this.editor) return;
1219
1220 const fitContent = () => {
1221 try {
1222 const originalEditor = this.editor!.getOriginalEditor();
1223 const modifiedEditor = this.editor!.getModifiedEditor();
Autoformatter9abf8032025-06-14 23:24:08 +00001224
David Crawshaw26f3f342025-06-14 19:58:32 +00001225 const originalHeight = originalEditor.getContentHeight();
1226 const modifiedHeight = modifiedEditor.getContentHeight();
Autoformatter9abf8032025-06-14 23:24:08 +00001227
David Crawshaw26f3f342025-06-14 19:58:32 +00001228 // Use the maximum height of both editors, plus some padding
1229 const maxHeight = Math.max(originalHeight, modifiedHeight) + 18; // 1 blank line bottom padding
Autoformatter9abf8032025-06-14 23:24:08 +00001230
David Crawshaw26f3f342025-06-14 19:58:32 +00001231 // Set both container and host height to enable proper scrolling
1232 if (this.container.value) {
1233 // Set explicit heights on both container and host
1234 this.container.value.style.height = `${maxHeight}px`;
1235 this.style.height = `${maxHeight}px`; // Update host element height
Autoformatter9abf8032025-06-14 23:24:08 +00001236
David Crawshaw26f3f342025-06-14 19:58:32 +00001237 // Emit the height change event BEFORE calling layout
1238 // This ensures parent containers resize first
Autoformatter9abf8032025-06-14 23:24:08 +00001239 this.dispatchEvent(
1240 new CustomEvent("monaco-height-changed", {
1241 detail: { height: maxHeight },
1242 bubbles: true,
1243 composed: true,
1244 }),
1245 );
1246
David Crawshaw26f3f342025-06-14 19:58:32 +00001247 // Layout after both this component and parents have updated
1248 setTimeout(() => {
1249 if (this.editor && this.container.value) {
1250 // Use explicit dimensions to ensure Monaco uses full available space
David Crawshawdba26b52025-06-15 00:33:45 +00001251 // Use clientWidth instead of offsetWidth to avoid border overflow
1252 const width = this.container.value.clientWidth;
David Crawshaw26f3f342025-06-14 19:58:32 +00001253 this.editor.layout({
1254 width: width,
Autoformatter9abf8032025-06-14 23:24:08 +00001255 height: maxHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +00001256 });
1257 }
1258 }, 10);
1259 }
1260 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +00001261 console.error("Error in fitContent:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +00001262 }
1263 };
1264
1265 // Store the fit function for external access
1266 this.fitEditorToContent = fitContent;
1267
1268 // Set up listeners for content size changes
1269 this.editor.getOriginalEditor().onDidContentSizeChange(fitContent);
1270 this.editor.getModifiedEditor().onDidContentSizeChange(fitContent);
1271
1272 // Initial fit
1273 fitContent();
1274 }
1275
1276 private fitEditorToContent: (() => void) | null = null;
1277
David Crawshawe2954ce2025-06-15 00:06:34 +00001278 /**
1279 * Set up window resize handler to ensure Monaco editor adapts to browser window changes
1280 */
1281 private setupWindowResizeHandler() {
1282 // Create a debounced resize handler to avoid too many layout calls
1283 let resizeTimeout: number | null = null;
Autoformatterad15b6c2025-06-15 00:29:26 +00001284
David Crawshawe2954ce2025-06-15 00:06:34 +00001285 this._windowResizeHandler = () => {
1286 // Clear any existing timeout
1287 if (resizeTimeout) {
1288 window.clearTimeout(resizeTimeout);
1289 }
Autoformatterad15b6c2025-06-15 00:29:26 +00001290
David Crawshawe2954ce2025-06-15 00:06:34 +00001291 // Debounce the resize to avoid excessive layout calls
1292 resizeTimeout = window.setTimeout(() => {
1293 if (this.editor && this.container.value) {
1294 // Trigger layout recalculation
1295 if (this.fitEditorToContent) {
1296 this.fitEditorToContent();
1297 } else {
1298 // Fallback: just trigger a layout with current container dimensions
David Crawshawdba26b52025-06-15 00:33:45 +00001299 // Use clientWidth/Height instead of offsetWidth/Height to avoid border overflow
1300 const width = this.container.value.clientWidth;
1301 const height = this.container.value.clientHeight;
David Crawshawe2954ce2025-06-15 00:06:34 +00001302 this.editor.layout({ width, height });
1303 }
1304 }
1305 }, 100); // 100ms debounce
1306 };
Autoformatterad15b6c2025-06-15 00:29:26 +00001307
David Crawshawe2954ce2025-06-15 00:06:34 +00001308 // Add the event listener
Autoformatterad15b6c2025-06-15 00:29:26 +00001309 window.addEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001310 }
1311
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001312 // Add resize observer to ensure editor resizes when container changes
philip.zeyligerc0a44592025-06-15 21:24:57 -07001313 async firstUpdated() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001314 // Initialize the editor
philip.zeyligerc0a44592025-06-15 21:24:57 -07001315 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001316
David Crawshawe2954ce2025-06-15 00:06:34 +00001317 // Set up window resize handler to ensure Monaco editor adapts to browser window changes
1318 this.setupWindowResizeHandler();
1319
David Crawshaw26f3f342025-06-14 19:58:32 +00001320 // For multi-file diff, we don't use ResizeObserver since we control the size
1321 // Instead, we rely on auto-sizing based on content
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001322
1323 // If editable, set up edit mode and content change listener
1324 if (this.editableRight && this.editor) {
1325 // Ensure the original editor is read-only
1326 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
1327 // Ensure the modified editor is editable
1328 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
1329 }
1330 }
1331
1332 private _resizeObserver: ResizeObserver | null = null;
David Crawshawe2954ce2025-06-15 00:06:34 +00001333 private _windowResizeHandler: (() => void) | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001334
philip.zeyliger7351cd92025-06-14 12:25:31 -07001335 /**
1336 * Add this Monaco editor instance to the global debug object
1337 * This allows inspection and debugging via browser console
1338 */
1339 private addToDebugGlobal() {
1340 try {
1341 // Initialize the debug global if it doesn't exist
1342 if (!(window as any).sketchDebug) {
1343 (window as any).sketchDebug = {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001344 monaco: window.monaco!,
philip.zeyliger7351cd92025-06-14 12:25:31 -07001345 editors: [],
1346 remeasureFonts: () => {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001347 window.monaco!.editor.remeasureFonts();
philip.zeyliger7351cd92025-06-14 12:25:31 -07001348 (window as any).sketchDebug.editors.forEach(
1349 (editor: any, index: number) => {
1350 if (editor && editor.layout) {
1351 editor.layout();
1352 }
1353 },
1354 );
1355 },
1356 layoutAll: () => {
1357 (window as any).sketchDebug.editors.forEach(
1358 (editor: any, index: number) => {
1359 if (editor && editor.layout) {
1360 editor.layout();
1361 }
1362 },
1363 );
1364 },
1365 getActiveEditors: () => {
1366 return (window as any).sketchDebug.editors.filter(
1367 (editor: any) => editor !== null,
1368 );
1369 },
1370 };
1371 }
1372
1373 // Add this editor to the debug collection
1374 if (this.editor) {
1375 (window as any).sketchDebug.editors.push(this.editor);
1376 }
1377 } catch (error) {
1378 console.error("Error adding Monaco editor to debug global:", error);
1379 }
1380 }
1381
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001382 disconnectedCallback() {
1383 super.disconnectedCallback();
1384
1385 try {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001386 // Remove editor from debug global before disposal
1387 if (this.editor && (window as any).sketchDebug?.editors) {
1388 const index = (window as any).sketchDebug.editors.indexOf(this.editor);
1389 if (index > -1) {
1390 (window as any).sketchDebug.editors[index] = null;
1391 }
1392 }
1393
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001394 // Clean up resources when element is removed
1395 if (this.editor) {
1396 this.editor.dispose();
1397 this.editor = undefined;
1398 }
1399
1400 // Dispose models to prevent memory leaks
1401 if (this.originalModel) {
1402 this.originalModel.dispose();
1403 this.originalModel = undefined;
1404 }
1405
1406 if (this.modifiedModel) {
1407 this.modifiedModel.dispose();
1408 this.modifiedModel = undefined;
1409 }
1410
David Crawshaw26f3f342025-06-14 19:58:32 +00001411 // Clean up resize observer (if any)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001412 if (this._resizeObserver) {
1413 this._resizeObserver.disconnect();
1414 this._resizeObserver = null;
1415 }
Autoformatter9abf8032025-06-14 23:24:08 +00001416
David Crawshaw26f3f342025-06-14 19:58:32 +00001417 // Clear the fit function reference
1418 this.fitEditorToContent = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001419
1420 // Remove document click handler if set
1421 if (this._documentClickHandler) {
1422 document.removeEventListener("click", this._documentClickHandler);
1423 this._documentClickHandler = null;
1424 }
Autoformatterad15b6c2025-06-15 00:29:26 +00001425
David Crawshawe2954ce2025-06-15 00:06:34 +00001426 // Remove window resize handler if set
1427 if (this._windowResizeHandler) {
Autoformatterad15b6c2025-06-15 00:29:26 +00001428 window.removeEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001429 this._windowResizeHandler = null;
1430 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001431 } catch (error) {
1432 console.error("Error in disconnectedCallback:", error);
1433 }
1434 }
1435
1436 // disconnectedCallback implementation is defined below
1437}
1438
1439declare global {
1440 interface HTMLElementTagNameMap {
1441 "sketch-monaco-view": CodeDiffEditor;
1442 }
1443}