blob: e15f3c92ab4d0ae5b9289f2b67307da56ae6b8b1 [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;
138 private container: Ref<HTMLElement> = createRef();
139 editor?: monaco.editor.IStandaloneDiffEditor;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700140
141 // Save state properties
142 @state() private saveState: "idle" | "modified" | "saving" | "saved" = "idle";
143 @state() private debounceSaveTimeout: number | null = null;
144 @state() private lastSavedContent: string = "";
145 @property() originalCode?: string = "// Original code here";
146 @property() modifiedCode?: string = "// Modified code here";
147 @property() originalFilename?: string = "original.js";
148 @property() modifiedFilename?: string = "modified.js";
149
150 /* Selected text and indicators */
151 @state()
152 private selectedText: string | null = null;
153
154 @state()
155 private selectionRange: {
156 startLineNumber: number;
157 startColumn: number;
158 endLineNumber: number;
159 endColumn: number;
160 } | null = null;
161
162 @state()
163 private showCommentIndicator: boolean = false;
164
165 @state()
166 private indicatorPosition: { top: number; left: number } = {
167 top: 0,
168 left: 0,
169 };
170
171 @state()
172 private showCommentBox: boolean = false;
173
174 @state()
175 private commentText: string = "";
176
177 @state()
178 private activeEditor: "original" | "modified" = "modified"; // Track which editor is active
179
180 // Custom event to request save action from external components
181 private requestSave() {
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000182 if (!this.editableRight || this.saveState !== "modified") return;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700183
184 this.saveState = "saving";
185
186 // Get current content from modified editor
187 const modifiedContent = this.modifiedModel?.getValue() || "";
188
189 // Create and dispatch the save event
190 const saveEvent = new CustomEvent("monaco-save", {
191 detail: {
192 path: this.modifiedFilename,
193 content: modifiedContent,
194 },
195 bubbles: true,
196 composed: true,
197 });
198
199 this.dispatchEvent(saveEvent);
200 }
201
202 // Method to be called from parent when save is complete
203 public notifySaveComplete(success: boolean) {
204 if (success) {
205 this.saveState = "saved";
206 // Update last saved content
207 this.lastSavedContent = this.modifiedModel?.getValue() || "";
208 // Reset to idle after a delay
209 setTimeout(() => {
210 this.saveState = "idle";
211 }, 2000);
212 } else {
213 // Return to modified state on error
214 this.saveState = "modified";
215 }
216 }
217
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000218 // Rescue people with strong save-constantly habits
219 private setupKeyboardShortcuts() {
220 if (!this.editor) return;
221 const modifiedEditor = this.editor.getModifiedEditor();
222 if (!modifiedEditor) return;
223
philip.zeyligerc0a44592025-06-15 21:24:57 -0700224 const monaco = window.monaco;
225 if (!monaco) return;
Autoformatter2f8464c2025-06-16 04:27:05 +0000226
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000227 modifiedEditor.addCommand(
228 monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
229 () => {
230 this.requestSave();
Autoformatter57893c22025-05-29 13:49:53 +0000231 },
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000232 );
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000233 }
234
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700235 // Setup content change listener for debounced save
236 private setupContentChangeListener() {
237 if (!this.editor || !this.editableRight) return;
238
239 const modifiedEditor = this.editor.getModifiedEditor();
240 if (!modifiedEditor || !modifiedEditor.getModel()) return;
241
242 // Store initial content
243 this.lastSavedContent = modifiedEditor.getModel()!.getValue();
244
245 // Listen for content changes
246 modifiedEditor.getModel()!.onDidChangeContent(() => {
247 const currentContent = modifiedEditor.getModel()!.getValue();
248
249 // Check if content has actually changed from last saved state
250 if (currentContent !== this.lastSavedContent) {
251 this.saveState = "modified";
252
253 // Debounce save request
254 if (this.debounceSaveTimeout) {
255 window.clearTimeout(this.debounceSaveTimeout);
256 }
257
258 this.debounceSaveTimeout = window.setTimeout(() => {
259 this.requestSave();
260 this.debounceSaveTimeout = null;
261 }, 1000); // 1 second debounce
262 }
263 });
264 }
265
266 static styles = css`
267 /* Save indicator styles */
268 .save-indicator {
269 position: absolute;
270 top: 4px;
271 right: 4px;
272 padding: 3px 8px;
273 border-radius: 3px;
274 font-size: 12px;
275 font-family: system-ui, sans-serif;
276 color: white;
277 z-index: 100;
278 opacity: 0.9;
279 pointer-events: none;
280 transition: opacity 0.3s ease;
281 }
282
Philip Zeyligere89b3082025-05-29 03:16:06 +0000283 .save-indicator.idle {
284 background-color: #6c757d;
285 }
286
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700287 .save-indicator.modified {
288 background-color: #f0ad4e;
289 }
290
291 .save-indicator.saving {
292 background-color: #5bc0de;
293 }
294
295 .save-indicator.saved {
296 background-color: #5cb85c;
297 }
298
299 /* Editor host styles */
300 :host {
301 --editor-width: 100%;
302 --editor-height: 100%;
303 display: flex;
David Crawshaw26f3f342025-06-14 19:58:32 +0000304 flex: none; /* Don't grow/shrink - size is determined by content */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700305 min-height: 0; /* Critical for flex layout */
306 position: relative; /* Establish positioning context */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700307 width: 100%; /* Take full width */
David Crawshaw26f3f342025-06-14 19:58:32 +0000308 /* Height will be set dynamically by setupAutoSizing */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700309 }
310 main {
David Crawshaw26f3f342025-06-14 19:58:32 +0000311 width: 100%;
312 height: 100%; /* Fill the host element completely */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700313 border: 1px solid #e0e0e0;
David Crawshaw26f3f342025-06-14 19:58:32 +0000314 flex: none; /* Size determined by parent */
315 min-height: 200px; /* Ensure a minimum height for the editor */
316 /* Remove absolute positioning - use normal block layout */
317 position: relative;
318 display: block;
David Crawshawdba26b52025-06-15 00:33:45 +0000319 box-sizing: border-box; /* Include border in width calculation */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700320 }
321
322 /* Comment indicator and box styles */
323 .comment-indicator {
324 position: fixed;
325 background-color: rgba(66, 133, 244, 0.9);
326 color: white;
327 border-radius: 3px;
328 padding: 3px 8px;
329 font-size: 12px;
330 cursor: pointer;
331 box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
332 z-index: 10000;
333 animation: fadeIn 0.2s ease-in-out;
334 display: flex;
335 align-items: center;
336 gap: 4px;
337 pointer-events: all;
338 }
339
340 .comment-indicator:hover {
341 background-color: rgba(66, 133, 244, 1);
342 }
343
344 .comment-indicator span {
345 line-height: 1;
346 }
347
348 .comment-box {
349 position: fixed;
350 background-color: white;
351 border: 1px solid #ddd;
352 border-radius: 4px;
353 box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
354 padding: 12px;
355 z-index: 10001;
356 width: 350px;
357 animation: fadeIn 0.2s ease-in-out;
358 max-height: 80vh;
359 overflow-y: auto;
360 }
361
362 .comment-box-header {
363 display: flex;
364 justify-content: space-between;
365 align-items: center;
366 margin-bottom: 8px;
367 }
368
369 .comment-box-header h3 {
370 margin: 0;
371 font-size: 14px;
372 font-weight: 500;
373 }
374
375 .close-button {
376 background: none;
377 border: none;
378 cursor: pointer;
379 font-size: 16px;
380 color: #666;
381 padding: 2px 6px;
382 }
383
384 .close-button:hover {
385 color: #333;
386 }
387
388 .selected-text-preview {
389 background-color: #f5f5f5;
390 border: 1px solid #eee;
391 border-radius: 3px;
392 padding: 8px;
393 margin-bottom: 10px;
394 font-family: monospace;
395 font-size: 12px;
396 max-height: 80px;
397 overflow-y: auto;
398 white-space: pre-wrap;
399 word-break: break-all;
400 }
401
402 .comment-textarea {
403 width: 100%;
404 min-height: 80px;
405 padding: 8px;
406 border: 1px solid #ddd;
407 border-radius: 3px;
408 resize: vertical;
409 font-family: inherit;
410 margin-bottom: 10px;
411 box-sizing: border-box;
412 }
413
414 .comment-actions {
415 display: flex;
416 justify-content: flex-end;
417 gap: 8px;
418 }
419
420 .comment-actions button {
421 padding: 6px 12px;
422 border-radius: 3px;
423 cursor: pointer;
424 font-size: 12px;
425 }
426
427 .cancel-button {
428 background-color: transparent;
429 border: 1px solid #ddd;
430 }
431
432 .cancel-button:hover {
433 background-color: #f5f5f5;
434 }
435
436 .submit-button {
437 background-color: #4285f4;
438 color: white;
439 border: none;
440 }
441
442 .submit-button:hover {
443 background-color: #3367d6;
444 }
445
446 @keyframes fadeIn {
447 from {
448 opacity: 0;
449 }
450 to {
451 opacity: 1;
452 }
453 }
454 `;
455
456 render() {
457 return html`
458 <style>
459 ${monacoStyles}
460 </style>
461 <main ${ref(this.container)}></main>
462
463 <!-- Save indicator - shown when editing -->
Philip Zeyligere89b3082025-05-29 03:16:06 +0000464 ${this.editableRight
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700465 ? html`
466 <div class="save-indicator ${this.saveState}">
Philip Zeyligere89b3082025-05-29 03:16:06 +0000467 ${this.saveState === "idle"
468 ? "Editable"
469 : this.saveState === "modified"
470 ? "Modified..."
471 : this.saveState === "saving"
472 ? "Saving..."
473 : this.saveState === "saved"
474 ? "Saved"
475 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700476 </div>
477 `
478 : ""}
479
480 <!-- Comment indicator - shown when text is selected -->
481 ${this.showCommentIndicator
482 ? html`
483 <div
484 class="comment-indicator"
485 style="top: ${this.indicatorPosition.top}px; left: ${this
486 .indicatorPosition.left}px;"
487 @click="${this.handleIndicatorClick}"
488 @mouseenter="${() => {
489 this._isHovering = true;
490 }}"
491 @mouseleave="${() => {
492 this._isHovering = false;
493 }}"
494 >
495 <span>💬</span>
496 <span>Add comment</span>
497 </div>
498 `
499 : ""}
500
501 <!-- Comment box - shown when indicator is clicked -->
502 ${this.showCommentBox
503 ? html`
504 <div
505 class="comment-box"
506 style="${this.calculateCommentBoxPosition()}"
507 @mouseenter="${() => {
508 this._isHovering = true;
509 }}"
510 @mouseleave="${() => {
511 this._isHovering = false;
512 }}"
513 >
514 <div class="comment-box-header">
515 <h3>Add comment</h3>
516 <button class="close-button" @click="${this.closeCommentBox}">
517 ×
518 </button>
519 </div>
520 <div class="selected-text-preview">${this.selectedText}</div>
521 <textarea
522 class="comment-textarea"
523 placeholder="Type your comment here..."
524 .value="${this.commentText}"
525 @input="${this.handleCommentInput}"
526 ></textarea>
527 <div class="comment-actions">
528 <button class="cancel-button" @click="${this.closeCommentBox}">
529 Cancel
530 </button>
531 <button class="submit-button" @click="${this.submitComment}">
Josh Bleecher Snyderafeafea2025-05-23 20:27:39 +0000532 Add
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700533 </button>
534 </div>
535 </div>
536 `
537 : ""}
538 `;
539 }
540
541 /**
542 * Calculate the optimal position for the comment box to keep it in view
543 */
544 private calculateCommentBoxPosition(): string {
545 // Get viewport dimensions
546 const viewportWidth = window.innerWidth;
547 const viewportHeight = window.innerHeight;
548
549 // Default position (below indicator)
550 let top = this.indicatorPosition.top + 30;
551 let left = this.indicatorPosition.left;
552
553 // Estimated box dimensions
554 const boxWidth = 350;
555 const boxHeight = 300;
556
557 // Check if box would go off the right edge
558 if (left + boxWidth > viewportWidth) {
559 left = viewportWidth - boxWidth - 20; // Keep 20px margin
560 }
561
562 // Check if box would go off the bottom
563 const bottomSpace = viewportHeight - top;
564 if (bottomSpace < boxHeight) {
565 // Not enough space below, try to position above if possible
566 if (this.indicatorPosition.top > boxHeight) {
567 // Position above the indicator
568 top = this.indicatorPosition.top - boxHeight - 10;
569 } else {
570 // Not enough space above either, position at top of viewport with margin
571 top = 10;
572 }
573 }
574
575 // Ensure box is never positioned off-screen
576 top = Math.max(10, top);
577 left = Math.max(10, left);
578
579 return `top: ${top}px; left: ${left}px;`;
580 }
581
582 setOriginalCode(code: string, filename?: string) {
583 this.originalCode = code;
584 if (filename) {
585 this.originalFilename = filename;
586 }
587
588 // Update the model if the editor is initialized
589 if (this.editor) {
590 const model = this.editor.getOriginalEditor().getModel();
591 if (model) {
592 model.setValue(code);
593 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700594 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700595 model,
596 this.getLanguageForFile(filename),
597 );
598 }
599 }
600 }
601 }
602
603 setModifiedCode(code: string, filename?: string) {
604 this.modifiedCode = code;
605 if (filename) {
606 this.modifiedFilename = filename;
607 }
608
609 // Update the model if the editor is initialized
610 if (this.editor) {
611 const model = this.editor.getModifiedEditor().getModel();
612 if (model) {
613 model.setValue(code);
614 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700615 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700616 model,
617 this.getLanguageForFile(filename),
618 );
619 }
620 }
621 }
622 }
623
Philip Zeyliger70273072025-05-28 18:26:14 +0000624 private _extensionToLanguageMap: Map<string, string> | null = null;
625
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700626 private getLanguageForFile(filename: string): string {
Philip Zeyliger70273072025-05-28 18:26:14 +0000627 // Get the file extension (including the dot for exact matching)
628 const extension = "." + (filename.split(".").pop()?.toLowerCase() || "");
629
630 // Build the extension-to-language map on first use
631 if (!this._extensionToLanguageMap) {
632 this._extensionToLanguageMap = new Map();
philip.zeyligerc0a44592025-06-15 21:24:57 -0700633 const languages = window.monaco!.languages.getLanguages();
Philip Zeyliger70273072025-05-28 18:26:14 +0000634
635 for (const language of languages) {
636 if (language.extensions) {
637 for (const ext of language.extensions) {
638 // Monaco extensions already include the dot, so use them directly
639 this._extensionToLanguageMap.set(ext.toLowerCase(), language.id);
640 }
641 }
642 }
643 }
644
645 return this._extensionToLanguageMap.get(extension) || "plaintext";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700646 }
647
648 /**
649 * Update editor options
650 */
651 setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
652 if (this.editor) {
653 this.editor.updateOptions(value);
David Crawshaw26f3f342025-06-14 19:58:32 +0000654 // Re-fit content after options change
655 if (this.fitEditorToContent) {
656 setTimeout(() => this.fitEditorToContent!(), 50);
657 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700658 }
659 }
660
661 /**
662 * Toggle hideUnchangedRegions feature
663 */
664 toggleHideUnchangedRegions(enabled: boolean) {
665 if (this.editor) {
666 this.editor.updateOptions({
667 hideUnchangedRegions: {
668 enabled: enabled,
669 contextLineCount: 3,
670 minimumLineCount: 3,
671 revealLineCount: 10,
672 },
673 });
David Crawshaw26f3f342025-06-14 19:58:32 +0000674 // Re-fit content after toggling
675 if (this.fitEditorToContent) {
676 setTimeout(() => this.fitEditorToContent!(), 100);
677 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700678 }
679 }
680
681 // Models for the editor
682 private originalModel?: monaco.editor.ITextModel;
683 private modifiedModel?: monaco.editor.ITextModel;
684
philip.zeyligerc0a44592025-06-15 21:24:57 -0700685 private async initializeEditor() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700686 try {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700687 // Load Monaco dynamically
688 const monaco = await loadMonaco();
Autoformatter2f8464c2025-06-16 04:27:05 +0000689
philip.zeyliger7351cd92025-06-14 12:25:31 -0700690 // Disable semantic validation globally for TypeScript/JavaScript if available
691 if (monaco.languages && monaco.languages.typescript) {
692 monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
693 noSemanticValidation: true,
694 });
695 monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
696 noSemanticValidation: true,
697 });
698 }
Autoformatter8c463622025-05-16 21:54:17 +0000699
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700700 // First time initialization
701 if (!this.editor) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000702 // Create the diff editor with auto-sizing configuration
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700703 this.editor = monaco.editor.createDiffEditor(this.container.value!, {
David Crawshaw26f3f342025-06-14 19:58:32 +0000704 automaticLayout: false, // We'll resize manually
Autoformatter8c463622025-05-16 21:54:17 +0000705 readOnly: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700706 theme: "vs", // Always use light mode
707 renderSideBySide: true,
708 ignoreTrimWhitespace: false,
David Crawshawf00c7b12025-06-15 00:24:46 +0000709 renderOverviewRuler: false, // Disable the overview ruler
David Crawshaw26f3f342025-06-14 19:58:32 +0000710 scrollbar: {
Autoformatter9abf8032025-06-14 23:24:08 +0000711 vertical: "hidden",
712 horizontal: "hidden",
David Crawshaw26f3f342025-06-14 19:58:32 +0000713 handleMouseWheel: false, // Let outer scroller eat the wheel
David Crawshawe2954ce2025-06-15 00:06:34 +0000714 useShadows: false, // Disable scrollbar shadows
715 verticalHasArrows: false, // Remove scrollbar arrows
716 horizontalHasArrows: false, // Remove scrollbar arrows
717 verticalScrollbarSize: 0, // Set scrollbar track width to 0
718 horizontalScrollbarSize: 0, // Set scrollbar track height to 0
David Crawshaw26f3f342025-06-14 19:58:32 +0000719 },
720 minimap: { enabled: false },
721 overviewRulerLanes: 0,
722 scrollBeyondLastLine: false,
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700723 // Focus on the differences by hiding unchanged regions
724 hideUnchangedRegions: {
725 enabled: true, // Enable the feature
726 contextLineCount: 3, // Show 3 lines of context around each difference
727 minimumLineCount: 3, // Hide regions only when they're at least 3 lines
728 revealLineCount: 10, // Show 10 lines when expanding a hidden region
729 },
730 });
731
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700732 // Set up selection change event listeners for both editors
733 this.setupSelectionChangeListeners();
734
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000735 this.setupKeyboardShortcuts();
736
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700737 // If this is an editable view, set the correct read-only state for each editor
738 if (this.editableRight) {
739 // Make sure the original editor is always read-only
740 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
741 // Make sure the modified editor is editable
742 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
743 }
philip.zeyliger7351cd92025-06-14 12:25:31 -0700744
David Crawshaw26f3f342025-06-14 19:58:32 +0000745 // Set up auto-sizing
746 this.setupAutoSizing();
747
philip.zeyliger7351cd92025-06-14 12:25:31 -0700748 // Add Monaco editor to debug global
749 this.addToDebugGlobal();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700750 }
751
752 // Create or update models
753 this.updateModels();
Autoformatter8c463622025-05-16 21:54:17 +0000754 // Set up content change listener
755 this.setupContentChangeListener();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700756
philip.zeyliger7351cd92025-06-14 12:25:31 -0700757 // Fix cursor positioning issues by ensuring fonts are loaded
philip.zeyliger7351cd92025-06-14 12:25:31 -0700758 document.fonts.ready.then(() => {
759 if (this.editor) {
760 monaco.editor.remeasureFonts();
David Crawshaw26f3f342025-06-14 19:58:32 +0000761 this.fitEditorToContent();
philip.zeyliger7351cd92025-06-14 12:25:31 -0700762 }
763 });
764
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700765 // Force layout recalculation after a short delay
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700766 setTimeout(() => {
767 if (this.editor) {
David Crawshaw26f3f342025-06-14 19:58:32 +0000768 this.fitEditorToContent();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700769 }
David Crawshaw26f3f342025-06-14 19:58:32 +0000770 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700771 } catch (error) {
772 console.error("Error initializing Monaco editor:", error);
773 }
774 }
775
776 /**
777 * Sets up event listeners for text selection in both editors.
778 * This enables showing the comment UI when users select text and
779 * manages the visibility of UI components based on user interactions.
780 */
781 private setupSelectionChangeListeners() {
782 try {
783 if (!this.editor) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700784 return;
785 }
786
787 // Get both original and modified editors
788 const originalEditor = this.editor.getOriginalEditor();
789 const modifiedEditor = this.editor.getModifiedEditor();
790
791 if (!originalEditor || !modifiedEditor) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700792 return;
793 }
794
795 // Add selection change listener to original editor
796 originalEditor.onDidChangeCursorSelection((e) => {
797 this.handleSelectionChange(e, originalEditor, "original");
798 });
799
800 // Add selection change listener to modified editor
801 modifiedEditor.onDidChangeCursorSelection((e) => {
802 this.handleSelectionChange(e, modifiedEditor, "modified");
803 });
804
805 // Create a debounced function for mouse move handling
806 let mouseMoveTimeout: number | null = null;
807 const handleMouseMove = () => {
808 // Clear any existing timeout
809 if (mouseMoveTimeout) {
810 window.clearTimeout(mouseMoveTimeout);
811 }
812
813 // If there's text selected and we're not showing the comment box, keep indicator visible
814 if (this.selectedText && !this.showCommentBox) {
815 this.showCommentIndicator = true;
816 this.requestUpdate();
817 }
818
819 // Set a new timeout to hide the indicator after a delay
820 mouseMoveTimeout = window.setTimeout(() => {
821 // Only hide if we're not showing the comment box and not actively hovering
822 if (!this.showCommentBox && !this._isHovering) {
823 this.showCommentIndicator = false;
824 this.requestUpdate();
825 }
826 }, 2000); // Hide after 2 seconds of inactivity
827 };
828
829 // Add mouse move listeners with debouncing
830 originalEditor.onMouseMove(() => handleMouseMove());
831 modifiedEditor.onMouseMove(() => handleMouseMove());
832
833 // Track hover state over the indicator and comment box
834 this._isHovering = false;
835
836 // Use the global document click handler to detect clicks outside
837 this._documentClickHandler = (e: MouseEvent) => {
838 try {
839 const target = e.target as HTMLElement;
840 const isIndicator =
841 target.matches(".comment-indicator") ||
842 !!target.closest(".comment-indicator");
843 const isCommentBox =
844 target.matches(".comment-box") || !!target.closest(".comment-box");
845
846 // If click is outside our UI elements
847 if (!isIndicator && !isCommentBox) {
848 // If we're not showing the comment box, hide the indicator
849 if (!this.showCommentBox) {
850 this.showCommentIndicator = false;
851 this.requestUpdate();
852 }
853 }
854 } catch (error) {
855 console.error("Error in document click handler:", error);
856 }
857 };
858
859 // Add the document click listener
860 document.addEventListener("click", this._documentClickHandler);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700861 } catch (error) {
862 console.error("Error setting up selection listeners:", error);
863 }
864 }
865
866 // Track mouse hover state
867 private _isHovering = false;
868
869 // Store document click handler for cleanup
870 private _documentClickHandler: ((e: MouseEvent) => void) | null = null;
871
872 /**
873 * Handle selection change events from either editor
874 */
875 private handleSelectionChange(
876 e: monaco.editor.ICursorSelectionChangedEvent,
877 editor: monaco.editor.IStandaloneCodeEditor,
878 editorType: "original" | "modified",
879 ) {
880 try {
881 // If we're not making a selection (just moving cursor), do nothing
882 if (e.selection.isEmpty()) {
883 // Don't hide indicator or box if already shown
884 if (!this.showCommentBox) {
885 this.selectedText = null;
886 this.selectionRange = null;
887 this.showCommentIndicator = false;
888 }
889 return;
890 }
891
892 // Get selected text
893 const model = editor.getModel();
894 if (!model) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700895 return;
896 }
897
898 // Make sure selection is within valid range
899 const lineCount = model.getLineCount();
900 if (
901 e.selection.startLineNumber > lineCount ||
902 e.selection.endLineNumber > lineCount
903 ) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700904 return;
905 }
906
907 // Store which editor is active
908 this.activeEditor = editorType;
909
910 // Store selection range
911 this.selectionRange = {
912 startLineNumber: e.selection.startLineNumber,
913 startColumn: e.selection.startColumn,
914 endLineNumber: e.selection.endLineNumber,
915 endColumn: e.selection.endColumn,
916 };
917
918 try {
Josh Bleecher Snyder444f7f02025-05-28 21:16:55 +0000919 // Expand selection to full lines for better context
920 const expandedSelection = {
921 startLineNumber: e.selection.startLineNumber,
922 startColumn: 1, // Start at beginning of line
923 endLineNumber: e.selection.endLineNumber,
924 endColumn: model.getLineMaxColumn(e.selection.endLineNumber), // End at end of line
925 };
926
927 // Get the selected text using the expanded selection
928 this.selectedText = model.getValueInRange(expandedSelection);
Autoformatter7ad1c7a2025-05-29 02:00:19 +0000929
Josh Bleecher Snyder444f7f02025-05-28 21:16:55 +0000930 // Update the selection range to reflect the full lines
931 this.selectionRange = {
932 startLineNumber: expandedSelection.startLineNumber,
933 startColumn: expandedSelection.startColumn,
934 endLineNumber: expandedSelection.endLineNumber,
935 endColumn: expandedSelection.endColumn,
936 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700937 } catch (error) {
938 console.error("Error getting selected text:", error);
939 return;
940 }
941
942 // If there's selected text, show the indicator
943 if (this.selectedText && this.selectedText.trim() !== "") {
944 // Calculate indicator position safely
945 try {
946 // Use the editor's DOM node as positioning context
947 const editorDomNode = editor.getDomNode();
948 if (!editorDomNode) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700949 return;
950 }
951
952 // Get position from editor
953 const position = {
954 lineNumber: e.selection.endLineNumber,
955 column: e.selection.endColumn,
956 };
957
958 // Use editor's built-in method for coordinate conversion
959 const selectionCoords = editor.getScrolledVisiblePosition(position);
960
961 if (selectionCoords) {
962 // Get accurate DOM position for the selection end
963 const editorRect = editorDomNode.getBoundingClientRect();
964
965 // Calculate the actual screen position
966 const screenLeft = editorRect.left + selectionCoords.left;
967 const screenTop = editorRect.top + selectionCoords.top;
968
969 // Store absolute screen coordinates
970 this.indicatorPosition = {
971 top: screenTop,
972 left: screenLeft + 10, // Slight offset
973 };
974
975 // Check window boundaries to ensure the indicator stays visible
976 const viewportWidth = window.innerWidth;
977 const viewportHeight = window.innerHeight;
978
979 // Keep indicator within viewport bounds
980 if (this.indicatorPosition.left + 150 > viewportWidth) {
981 this.indicatorPosition.left = viewportWidth - 160;
982 }
983
984 if (this.indicatorPosition.top + 40 > viewportHeight) {
985 this.indicatorPosition.top = viewportHeight - 50;
986 }
987
988 // Show the indicator
989 this.showCommentIndicator = true;
990
991 // Request an update to ensure UI reflects changes
992 this.requestUpdate();
993 }
994 } catch (error) {
995 console.error("Error positioning comment indicator:", error);
996 }
997 }
998 } catch (error) {
999 console.error("Error handling selection change:", error);
1000 }
1001 }
1002
1003 /**
1004 * Handle click on the comment indicator
1005 */
1006 private handleIndicatorClick(e: Event) {
1007 try {
1008 e.stopPropagation();
1009 e.preventDefault();
1010
1011 this.showCommentBox = true;
1012 this.commentText = ""; // Reset comment text
1013
1014 // Don't hide the indicator while comment box is shown
1015 this.showCommentIndicator = true;
1016
1017 // Ensure UI updates
1018 this.requestUpdate();
1019 } catch (error) {
1020 console.error("Error handling indicator click:", error);
1021 }
1022 }
1023
1024 /**
1025 * Handle changes to the comment text
1026 */
1027 private handleCommentInput(e: Event) {
1028 const target = e.target as HTMLTextAreaElement;
1029 this.commentText = target.value;
1030 }
1031
1032 /**
1033 * Close the comment box
1034 */
1035 private closeCommentBox() {
1036 this.showCommentBox = false;
1037 // Also hide the indicator
1038 this.showCommentIndicator = false;
1039 }
1040
1041 /**
1042 * Submit the comment
1043 */
1044 private submitComment() {
1045 try {
1046 if (!this.selectedText || !this.commentText) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001047 return;
1048 }
1049
1050 // Get the correct filename based on active editor
1051 const fileContext =
1052 this.activeEditor === "original"
1053 ? this.originalFilename || "Original file"
1054 : this.modifiedFilename || "Modified file";
1055
1056 // Include editor info to make it clear which version was commented on
1057 const editorLabel =
1058 this.activeEditor === "original" ? "[Original]" : "[Modified]";
1059
Josh Bleecher Snyderb34b8b32025-05-28 21:00:56 +00001060 // Add line number information if available
1061 let lineInfo = "";
1062 if (this.selectionRange) {
1063 const startLine = this.selectionRange.startLineNumber;
1064 const endLine = this.selectionRange.endLineNumber;
1065 if (startLine === endLine) {
1066 lineInfo = ` (line ${startLine})`;
1067 } else {
1068 lineInfo = ` (lines ${startLine}-${endLine})`;
1069 }
1070 }
1071
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001072 // Format the comment in a readable way
Josh Bleecher Snyderb34b8b32025-05-28 21:00:56 +00001073 const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}${lineInfo}:\n${this.selectedText}\n\`\`\`\n\n${this.commentText}`;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001074
1075 // Close UI before dispatching to prevent interaction conflicts
1076 this.closeCommentBox();
1077
1078 // Use setTimeout to ensure the UI has updated before sending the event
1079 setTimeout(() => {
1080 try {
1081 // Dispatch a custom event with the comment details
1082 const event = new CustomEvent("monaco-comment", {
1083 detail: {
1084 fileContext,
1085 selectedText: this.selectedText,
1086 commentText: this.commentText,
1087 formattedComment,
1088 selectionRange: this.selectionRange,
1089 activeEditor: this.activeEditor,
1090 },
1091 bubbles: true,
1092 composed: true,
1093 });
1094
1095 this.dispatchEvent(event);
1096 } catch (error) {
1097 console.error("Error dispatching comment event:", error);
1098 }
1099 }, 0);
1100 } catch (error) {
1101 console.error("Error submitting comment:", error);
1102 this.closeCommentBox();
1103 }
1104 }
1105
1106 private updateModels() {
1107 try {
1108 // Get language based on filename
1109 const originalLang = this.getLanguageForFile(this.originalFilename || "");
1110 const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
1111
1112 // Always create new models with unique URIs based on timestamp to avoid conflicts
1113 const timestamp = new Date().getTime();
1114 // TODO: Could put filename in these URIs; unclear how they're used right now.
philip.zeyligerc0a44592025-06-15 21:24:57 -07001115 const originalUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001116 `file:///original-${timestamp}.${originalLang}`,
1117 );
philip.zeyligerc0a44592025-06-15 21:24:57 -07001118 const modifiedUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001119 `file:///modified-${timestamp}.${modifiedLang}`,
1120 );
1121
1122 // Store references to old models
1123 const oldOriginalModel = this.originalModel;
1124 const oldModifiedModel = this.modifiedModel;
1125
1126 // Nullify instance variables to prevent accidental use
1127 this.originalModel = undefined;
1128 this.modifiedModel = undefined;
1129
1130 // Clear the editor model first to release Monaco's internal references
1131 if (this.editor) {
1132 this.editor.setModel(null);
1133 }
1134
1135 // Now it's safe to dispose the old models
1136 if (oldOriginalModel) {
1137 oldOriginalModel.dispose();
1138 }
1139
1140 if (oldModifiedModel) {
1141 oldModifiedModel.dispose();
1142 }
1143
1144 // Create new models
philip.zeyligerc0a44592025-06-15 21:24:57 -07001145 this.originalModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001146 this.originalCode || "",
1147 originalLang,
1148 originalUri,
1149 );
1150
philip.zeyligerc0a44592025-06-15 21:24:57 -07001151 this.modifiedModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001152 this.modifiedCode || "",
1153 modifiedLang,
1154 modifiedUri,
1155 );
1156
1157 // Set the new models on the editor
1158 if (this.editor) {
1159 this.editor.setModel({
1160 original: this.originalModel,
1161 modified: this.modifiedModel,
1162 });
Autoformatter9abf8032025-06-14 23:24:08 +00001163
David Crawshaw26f3f342025-06-14 19:58:32 +00001164 // Set initial hideUnchangedRegions state (default to enabled/collapsed)
1165 this.editor.updateOptions({
1166 hideUnchangedRegions: {
1167 enabled: true, // Default to collapsed state
1168 contextLineCount: 3,
1169 minimumLineCount: 3,
1170 revealLineCount: 10,
1171 },
1172 });
Autoformatter9abf8032025-06-14 23:24:08 +00001173
David Crawshaw26f3f342025-06-14 19:58:32 +00001174 // Fit content after setting new models
1175 if (this.fitEditorToContent) {
1176 setTimeout(() => this.fitEditorToContent!(), 50);
1177 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001178 }
1179 this.setupContentChangeListener();
1180 } catch (error) {
1181 console.error("Error updating Monaco models:", error);
1182 }
1183 }
1184
philip.zeyligerc0a44592025-06-15 21:24:57 -07001185 async updated(changedProperties: Map<string, any>) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001186 // If any relevant properties changed, just update the models
1187 if (
1188 changedProperties.has("originalCode") ||
1189 changedProperties.has("modifiedCode") ||
1190 changedProperties.has("originalFilename") ||
1191 changedProperties.has("modifiedFilename") ||
1192 changedProperties.has("editableRight")
1193 ) {
1194 if (this.editor) {
1195 this.updateModels();
1196
David Crawshaw26f3f342025-06-14 19:58:32 +00001197 // Force auto-sizing after model updates
1198 // Use a slightly longer delay to ensure layout is stable
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001199 setTimeout(() => {
David Crawshaw26f3f342025-06-14 19:58:32 +00001200 if (this.fitEditorToContent) {
1201 this.fitEditorToContent();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001202 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001203 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001204 } else {
1205 // If the editor isn't initialized yet but we received content,
1206 // initialize it now
philip.zeyligerc0a44592025-06-15 21:24:57 -07001207 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001208 }
1209 }
1210 }
1211
David Crawshaw26f3f342025-06-14 19:58:32 +00001212 // Set up auto-sizing for multi-file diff view
1213 private setupAutoSizing() {
1214 if (!this.editor) return;
1215
1216 const fitContent = () => {
1217 try {
1218 const originalEditor = this.editor!.getOriginalEditor();
1219 const modifiedEditor = this.editor!.getModifiedEditor();
Autoformatter9abf8032025-06-14 23:24:08 +00001220
David Crawshaw26f3f342025-06-14 19:58:32 +00001221 const originalHeight = originalEditor.getContentHeight();
1222 const modifiedHeight = modifiedEditor.getContentHeight();
Autoformatter9abf8032025-06-14 23:24:08 +00001223
David Crawshaw26f3f342025-06-14 19:58:32 +00001224 // Use the maximum height of both editors, plus some padding
1225 const maxHeight = Math.max(originalHeight, modifiedHeight) + 18; // 1 blank line bottom padding
Autoformatter9abf8032025-06-14 23:24:08 +00001226
David Crawshaw26f3f342025-06-14 19:58:32 +00001227 // Set both container and host height to enable proper scrolling
1228 if (this.container.value) {
1229 // Set explicit heights on both container and host
1230 this.container.value.style.height = `${maxHeight}px`;
1231 this.style.height = `${maxHeight}px`; // Update host element height
Autoformatter9abf8032025-06-14 23:24:08 +00001232
David Crawshaw26f3f342025-06-14 19:58:32 +00001233 // Emit the height change event BEFORE calling layout
1234 // This ensures parent containers resize first
Autoformatter9abf8032025-06-14 23:24:08 +00001235 this.dispatchEvent(
1236 new CustomEvent("monaco-height-changed", {
1237 detail: { height: maxHeight },
1238 bubbles: true,
1239 composed: true,
1240 }),
1241 );
1242
David Crawshaw26f3f342025-06-14 19:58:32 +00001243 // Layout after both this component and parents have updated
1244 setTimeout(() => {
1245 if (this.editor && this.container.value) {
1246 // Use explicit dimensions to ensure Monaco uses full available space
David Crawshawdba26b52025-06-15 00:33:45 +00001247 // Use clientWidth instead of offsetWidth to avoid border overflow
1248 const width = this.container.value.clientWidth;
David Crawshaw26f3f342025-06-14 19:58:32 +00001249 this.editor.layout({
1250 width: width,
Autoformatter9abf8032025-06-14 23:24:08 +00001251 height: maxHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +00001252 });
1253 }
1254 }, 10);
1255 }
1256 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +00001257 console.error("Error in fitContent:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +00001258 }
1259 };
1260
1261 // Store the fit function for external access
1262 this.fitEditorToContent = fitContent;
1263
1264 // Set up listeners for content size changes
1265 this.editor.getOriginalEditor().onDidContentSizeChange(fitContent);
1266 this.editor.getModifiedEditor().onDidContentSizeChange(fitContent);
1267
1268 // Initial fit
1269 fitContent();
1270 }
1271
1272 private fitEditorToContent: (() => void) | null = null;
1273
David Crawshawe2954ce2025-06-15 00:06:34 +00001274 /**
1275 * Set up window resize handler to ensure Monaco editor adapts to browser window changes
1276 */
1277 private setupWindowResizeHandler() {
1278 // Create a debounced resize handler to avoid too many layout calls
1279 let resizeTimeout: number | null = null;
Autoformatterad15b6c2025-06-15 00:29:26 +00001280
David Crawshawe2954ce2025-06-15 00:06:34 +00001281 this._windowResizeHandler = () => {
1282 // Clear any existing timeout
1283 if (resizeTimeout) {
1284 window.clearTimeout(resizeTimeout);
1285 }
Autoformatterad15b6c2025-06-15 00:29:26 +00001286
David Crawshawe2954ce2025-06-15 00:06:34 +00001287 // Debounce the resize to avoid excessive layout calls
1288 resizeTimeout = window.setTimeout(() => {
1289 if (this.editor && this.container.value) {
1290 // Trigger layout recalculation
1291 if (this.fitEditorToContent) {
1292 this.fitEditorToContent();
1293 } else {
1294 // Fallback: just trigger a layout with current container dimensions
David Crawshawdba26b52025-06-15 00:33:45 +00001295 // Use clientWidth/Height instead of offsetWidth/Height to avoid border overflow
1296 const width = this.container.value.clientWidth;
1297 const height = this.container.value.clientHeight;
David Crawshawe2954ce2025-06-15 00:06:34 +00001298 this.editor.layout({ width, height });
1299 }
1300 }
1301 }, 100); // 100ms debounce
1302 };
Autoformatterad15b6c2025-06-15 00:29:26 +00001303
David Crawshawe2954ce2025-06-15 00:06:34 +00001304 // Add the event listener
Autoformatterad15b6c2025-06-15 00:29:26 +00001305 window.addEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001306 }
1307
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001308 // Add resize observer to ensure editor resizes when container changes
philip.zeyligerc0a44592025-06-15 21:24:57 -07001309 async firstUpdated() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001310 // Initialize the editor
philip.zeyligerc0a44592025-06-15 21:24:57 -07001311 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001312
David Crawshawe2954ce2025-06-15 00:06:34 +00001313 // Set up window resize handler to ensure Monaco editor adapts to browser window changes
1314 this.setupWindowResizeHandler();
1315
David Crawshaw26f3f342025-06-14 19:58:32 +00001316 // For multi-file diff, we don't use ResizeObserver since we control the size
1317 // Instead, we rely on auto-sizing based on content
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001318
1319 // If editable, set up edit mode and content change listener
1320 if (this.editableRight && this.editor) {
1321 // Ensure the original editor is read-only
1322 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
1323 // Ensure the modified editor is editable
1324 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
1325 }
1326 }
1327
1328 private _resizeObserver: ResizeObserver | null = null;
David Crawshawe2954ce2025-06-15 00:06:34 +00001329 private _windowResizeHandler: (() => void) | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001330
philip.zeyliger7351cd92025-06-14 12:25:31 -07001331 /**
1332 * Add this Monaco editor instance to the global debug object
1333 * This allows inspection and debugging via browser console
1334 */
1335 private addToDebugGlobal() {
1336 try {
1337 // Initialize the debug global if it doesn't exist
1338 if (!(window as any).sketchDebug) {
1339 (window as any).sketchDebug = {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001340 monaco: window.monaco!,
philip.zeyliger7351cd92025-06-14 12:25:31 -07001341 editors: [],
1342 remeasureFonts: () => {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001343 window.monaco!.editor.remeasureFonts();
philip.zeyliger7351cd92025-06-14 12:25:31 -07001344 (window as any).sketchDebug.editors.forEach(
1345 (editor: any, index: number) => {
1346 if (editor && editor.layout) {
1347 editor.layout();
1348 }
1349 },
1350 );
1351 },
1352 layoutAll: () => {
1353 (window as any).sketchDebug.editors.forEach(
1354 (editor: any, index: number) => {
1355 if (editor && editor.layout) {
1356 editor.layout();
1357 }
1358 },
1359 );
1360 },
1361 getActiveEditors: () => {
1362 return (window as any).sketchDebug.editors.filter(
1363 (editor: any) => editor !== null,
1364 );
1365 },
1366 };
1367 }
1368
1369 // Add this editor to the debug collection
1370 if (this.editor) {
1371 (window as any).sketchDebug.editors.push(this.editor);
1372 }
1373 } catch (error) {
1374 console.error("Error adding Monaco editor to debug global:", error);
1375 }
1376 }
1377
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001378 disconnectedCallback() {
1379 super.disconnectedCallback();
1380
1381 try {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001382 // Remove editor from debug global before disposal
1383 if (this.editor && (window as any).sketchDebug?.editors) {
1384 const index = (window as any).sketchDebug.editors.indexOf(this.editor);
1385 if (index > -1) {
1386 (window as any).sketchDebug.editors[index] = null;
1387 }
1388 }
1389
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001390 // Clean up resources when element is removed
1391 if (this.editor) {
1392 this.editor.dispose();
1393 this.editor = undefined;
1394 }
1395
1396 // Dispose models to prevent memory leaks
1397 if (this.originalModel) {
1398 this.originalModel.dispose();
1399 this.originalModel = undefined;
1400 }
1401
1402 if (this.modifiedModel) {
1403 this.modifiedModel.dispose();
1404 this.modifiedModel = undefined;
1405 }
1406
David Crawshaw26f3f342025-06-14 19:58:32 +00001407 // Clean up resize observer (if any)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001408 if (this._resizeObserver) {
1409 this._resizeObserver.disconnect();
1410 this._resizeObserver = null;
1411 }
Autoformatter9abf8032025-06-14 23:24:08 +00001412
David Crawshaw26f3f342025-06-14 19:58:32 +00001413 // Clear the fit function reference
1414 this.fitEditorToContent = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001415
1416 // Remove document click handler if set
1417 if (this._documentClickHandler) {
1418 document.removeEventListener("click", this._documentClickHandler);
1419 this._documentClickHandler = null;
1420 }
Autoformatterad15b6c2025-06-15 00:29:26 +00001421
David Crawshawe2954ce2025-06-15 00:06:34 +00001422 // Remove window resize handler if set
1423 if (this._windowResizeHandler) {
Autoformatterad15b6c2025-06-15 00:29:26 +00001424 window.removeEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001425 this._windowResizeHandler = null;
1426 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001427 } catch (error) {
1428 console.error("Error in disconnectedCallback:", error);
1429 }
1430 }
1431
1432 // disconnectedCallback implementation is defined below
1433}
1434
1435declare global {
1436 interface HTMLElementTagNameMap {
1437 "sketch-monaco-view": CodeDiffEditor;
1438 }
1439}