blob: 2ab89fa98ca8b48802f9ad73ace3de1c5ad438c0 [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any, no-async-promise-executor, @typescript-eslint/ban-ts-comment */
Philip Zeyliger272a90e2025-05-16 14:49:51 -07002import { css, html, LitElement } from "lit";
3import { customElement, property, state } from "lit/decorators.js";
4import { createRef, Ref, ref } from "lit/directives/ref.js";
5
6// See https://rodydavis.com/posts/lit-monaco-editor for some ideas.
7
philip.zeyligerc0a44592025-06-15 21:24:57 -07008import type * as monaco from "monaco-editor";
Philip Zeyliger272a90e2025-05-16 14:49:51 -07009
philip.zeyligerc0a44592025-06-15 21:24:57 -070010// Monaco is loaded dynamically - see loadMonaco() function
11declare global {
12 interface Window {
13 monaco?: typeof monaco;
14 }
15}
16
17// Monaco hash will be injected at build time
18declare const __MONACO_HASH__: string;
19
20// Load Monaco editor dynamically
21let monacoLoadPromise: Promise<any> | null = null;
22
23function loadMonaco(): Promise<typeof monaco> {
24 if (monacoLoadPromise) {
25 return monacoLoadPromise;
26 }
27
28 if (window.monaco) {
29 return Promise.resolve(window.monaco);
30 }
31
Philip Zeyliger3cde2822025-06-21 09:32:38 -070032 monacoLoadPromise = new Promise(async (resolve, reject) => {
33 try {
34 // Check if we're in development mode
35 const isDev = __MONACO_HASH__ === "dev";
Autoformatter2f8464c2025-06-16 04:27:05 +000036
Philip Zeyliger3cde2822025-06-21 09:32:38 -070037 if (isDev) {
38 // In development mode, import Monaco directly
39 const monaco = await import("monaco-editor");
40 window.monaco = monaco;
41 resolve(monaco);
philip.zeyligerc0a44592025-06-15 21:24:57 -070042 } else {
Philip Zeyliger3cde2822025-06-21 09:32:38 -070043 // In production mode, load from external bundle
44 const monacoHash = __MONACO_HASH__;
Autoformatter2f8464c2025-06-16 04:27:05 +000045
Philip Zeyliger3cde2822025-06-21 09:32:38 -070046 // Try to load the external Monaco bundle
47 const script = document.createElement("script");
48 script.onload = () => {
49 // The Monaco bundle should set window.monaco
50 if (window.monaco) {
51 resolve(window.monaco);
52 } else {
53 reject(new Error("Monaco not loaded from external bundle"));
54 }
55 };
56 script.onerror = (error) => {
57 console.warn("Failed to load external Monaco bundle:", error);
58 reject(new Error("Monaco external bundle failed to load"));
59 };
60
61 // Don't set type="module" since we're using IIFE format
62 script.src = `./static/monaco-standalone-${monacoHash}.js`;
63 document.head.appendChild(script);
64 }
65 } catch (error) {
66 reject(error);
67 }
philip.zeyligerc0a44592025-06-15 21:24:57 -070068 });
69
70 return monacoLoadPromise;
71}
Philip Zeyliger272a90e2025-05-16 14:49:51 -070072
73// Define Monaco CSS styles as a string constant
74const monacoStyles = `
75 /* Import Monaco editor styles */
76 @import url('./static/monaco/min/vs/editor/editor.main.css');
77
78 /* Codicon font is now defined globally in sketch-app-shell.css */
79
80 /* Custom Monaco styles */
81 .monaco-editor {
82 width: 100%;
83 height: 100%;
84 }
85
Philip Zeyliger272a90e2025-05-16 14:49:51 -070086 /* Ensure light theme colors */
87 .monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input {
88 background-color: var(--monaco-editor-bg, #ffffff) !important;
89 }
90
91 .monaco-editor .margin {
92 background-color: var(--monaco-editor-margin, #f5f5f5) !important;
93 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -070094
95 /* Glyph decoration styles - only show on hover */
96 .comment-glyph-decoration {
97 width: 16px !important;
98 height: 18px !important;
99 cursor: pointer;
100 opacity: 0;
101 transition: opacity 0.2s ease;
102 }
103
104 .comment-glyph-decoration:before {
105 content: '💬';
106 font-size: 12px;
107 line-height: 18px;
108 width: 16px;
109 height: 18px;
110 display: block;
111 text-align: center;
112 }
113
114 .comment-glyph-decoration.hover-visible {
115 opacity: 1;
116 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700117`;
118
119// Configure Monaco to use local workers with correct relative paths
120// Monaco looks for this global configuration to determine how to load web workers
121// @ts-ignore - MonacoEnvironment is added to the global scope at runtime
122self.MonacoEnvironment = {
123 getWorkerUrl: function (_moduleId, label) {
124 if (label === "json") {
125 return "./static/json.worker.js";
126 }
127 if (label === "css" || label === "scss" || label === "less") {
128 return "./static/css.worker.js";
129 }
130 if (label === "html" || label === "handlebars" || label === "razor") {
131 return "./static/html.worker.js";
132 }
133 if (label === "typescript" || label === "javascript") {
134 return "./static/ts.worker.js";
135 }
136 return "./static/editor.worker.js";
137 },
138};
139
140@customElement("sketch-monaco-view")
141export class CodeDiffEditor extends LitElement {
142 // Editable state
143 @property({ type: Boolean, attribute: "editable-right" })
144 editableRight?: boolean;
philip.zeyliger6b8b7662025-06-16 03:06:30 +0000145
146 // Inline diff mode (for mobile)
147 @property({ type: Boolean, attribute: "inline" })
148 inline?: boolean;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700149 private container: Ref<HTMLElement> = createRef();
150 editor?: monaco.editor.IStandaloneDiffEditor;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700151
152 // Save state properties
153 @state() private saveState: "idle" | "modified" | "saving" | "saved" = "idle";
154 @state() private debounceSaveTimeout: number | null = null;
155 @state() private lastSavedContent: string = "";
156 @property() originalCode?: string = "// Original code here";
157 @property() modifiedCode?: string = "// Modified code here";
158 @property() originalFilename?: string = "original.js";
159 @property() modifiedFilename?: string = "modified.js";
160
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700161 // Comment system state
162 @state() private showCommentBox: boolean = false;
163 @state() private commentText: string = "";
164 @state() private selectedLines: {
165 startLine: number;
166 endLine: number;
167 editorType: "original" | "modified";
168 text: string;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700169 } | null = null;
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700170 @state() private commentBoxPosition: { top: number; left: number } = {
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700171 top: 0,
172 left: 0,
173 };
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700174 @state() private isDragging: boolean = false;
175 @state() private dragStartLine: number | null = null;
176 @state() private dragStartEditor: "original" | "modified" | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700177
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700178 // Track visible glyphs to ensure proper cleanup
179 private visibleGlyphs: Set<string> = new Set();
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700180
181 // Custom event to request save action from external components
182 private requestSave() {
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000183 if (!this.editableRight || this.saveState !== "modified") return;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700184
185 this.saveState = "saving";
186
187 // Get current content from modified editor
188 const modifiedContent = this.modifiedModel?.getValue() || "";
189
190 // Create and dispatch the save event
191 const saveEvent = new CustomEvent("monaco-save", {
192 detail: {
193 path: this.modifiedFilename,
194 content: modifiedContent,
195 },
196 bubbles: true,
197 composed: true,
198 });
199
200 this.dispatchEvent(saveEvent);
201 }
202
203 // Method to be called from parent when save is complete
204 public notifySaveComplete(success: boolean) {
205 if (success) {
206 this.saveState = "saved";
207 // Update last saved content
208 this.lastSavedContent = this.modifiedModel?.getValue() || "";
209 // Reset to idle after a delay
210 setTimeout(() => {
211 this.saveState = "idle";
212 }, 2000);
213 } else {
214 // Return to modified state on error
215 this.saveState = "modified";
216 }
217 }
218
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000219 // Rescue people with strong save-constantly habits
220 private setupKeyboardShortcuts() {
221 if (!this.editor) return;
222 const modifiedEditor = this.editor.getModifiedEditor();
223 if (!modifiedEditor) return;
224
philip.zeyligerc0a44592025-06-15 21:24:57 -0700225 const monaco = window.monaco;
226 if (!monaco) return;
Autoformatter2f8464c2025-06-16 04:27:05 +0000227
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000228 modifiedEditor.addCommand(
229 monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
230 () => {
231 this.requestSave();
Autoformatter57893c22025-05-29 13:49:53 +0000232 },
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000233 );
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +0000234 }
235
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700236 // Setup content change listener for debounced save
237 private setupContentChangeListener() {
238 if (!this.editor || !this.editableRight) return;
239
240 const modifiedEditor = this.editor.getModifiedEditor();
241 if (!modifiedEditor || !modifiedEditor.getModel()) return;
242
243 // Store initial content
244 this.lastSavedContent = modifiedEditor.getModel()!.getValue();
245
246 // Listen for content changes
247 modifiedEditor.getModel()!.onDidChangeContent(() => {
248 const currentContent = modifiedEditor.getModel()!.getValue();
249
250 // Check if content has actually changed from last saved state
251 if (currentContent !== this.lastSavedContent) {
252 this.saveState = "modified";
253
254 // Debounce save request
255 if (this.debounceSaveTimeout) {
256 window.clearTimeout(this.debounceSaveTimeout);
257 }
258
259 this.debounceSaveTimeout = window.setTimeout(() => {
260 this.requestSave();
261 this.debounceSaveTimeout = null;
262 }, 1000); // 1 second debounce
263 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700264
265 // Update glyph decorations when content changes
266 setTimeout(() => {
267 if (this.editor && this.modifiedModel) {
268 this.addGlyphDecorationsToEditor(
269 this.editor.getModifiedEditor(),
270 this.modifiedModel,
271 "modified",
272 );
273 }
274 }, 50);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700275 });
276 }
277
278 static styles = css`
279 /* Save indicator styles */
280 .save-indicator {
281 position: absolute;
282 top: 4px;
283 right: 4px;
284 padding: 3px 8px;
285 border-radius: 3px;
286 font-size: 12px;
287 font-family: system-ui, sans-serif;
288 color: white;
289 z-index: 100;
290 opacity: 0.9;
291 pointer-events: none;
292 transition: opacity 0.3s ease;
293 }
294
Philip Zeyligere89b3082025-05-29 03:16:06 +0000295 .save-indicator.idle {
296 background-color: #6c757d;
297 }
298
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700299 .save-indicator.modified {
300 background-color: #f0ad4e;
301 }
302
303 .save-indicator.saving {
304 background-color: #5bc0de;
305 }
306
307 .save-indicator.saved {
308 background-color: #5cb85c;
309 }
310
311 /* Editor host styles */
312 :host {
313 --editor-width: 100%;
314 --editor-height: 100%;
315 display: flex;
David Crawshaw26f3f342025-06-14 19:58:32 +0000316 flex: none; /* Don't grow/shrink - size is determined by content */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700317 min-height: 0; /* Critical for flex layout */
318 position: relative; /* Establish positioning context */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700319 width: 100%; /* Take full width */
David Crawshaw26f3f342025-06-14 19:58:32 +0000320 /* Height will be set dynamically by setupAutoSizing */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700321 }
322 main {
David Crawshaw26f3f342025-06-14 19:58:32 +0000323 width: 100%;
324 height: 100%; /* Fill the host element completely */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700325 border: 1px solid #e0e0e0;
David Crawshaw26f3f342025-06-14 19:58:32 +0000326 flex: none; /* Size determined by parent */
327 min-height: 200px; /* Ensure a minimum height for the editor */
328 /* Remove absolute positioning - use normal block layout */
329 position: relative;
330 display: block;
David Crawshawdba26b52025-06-15 00:33:45 +0000331 box-sizing: border-box; /* Include border in width calculation */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700332 }
333
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700334 /* Comment box styles */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700335 .comment-box {
336 position: fixed;
337 background-color: white;
338 border: 1px solid #ddd;
339 border-radius: 4px;
340 box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
341 padding: 12px;
342 z-index: 10001;
philip.zeyliger0accea12025-07-01 09:59:18 -0700343 width: 600px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700344 animation: fadeIn 0.2s ease-in-out;
345 max-height: 80vh;
346 overflow-y: auto;
347 }
348
349 .comment-box-header {
350 display: flex;
351 justify-content: space-between;
352 align-items: center;
353 margin-bottom: 8px;
354 }
355
356 .comment-box-header h3 {
357 margin: 0;
358 font-size: 14px;
359 font-weight: 500;
360 }
361
362 .close-button {
363 background: none;
364 border: none;
365 cursor: pointer;
366 font-size: 16px;
367 color: #666;
368 padding: 2px 6px;
369 }
370
371 .close-button:hover {
372 color: #333;
373 }
374
375 .selected-text-preview {
376 background-color: #f5f5f5;
377 border: 1px solid #eee;
378 border-radius: 3px;
379 padding: 8px;
380 margin-bottom: 10px;
381 font-family: monospace;
382 font-size: 12px;
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700383 overflow-y: auto;
384 white-space: pre-wrap;
385 word-break: break-all;
philip.zeyliger0accea12025-07-01 09:59:18 -0700386 line-height: 1.4;
387 }
388
389 .selected-text-preview.small-selection {
390 /* For selections of 10 lines or fewer, ensure all content is visible */
391 max-height: none;
392 }
393
394 .selected-text-preview.large-selection {
395 /* For selections larger than 10 lines, limit height with scroll */
396 max-height: 280px; /* Approximately 10 lines at 12px font with 1.4 line-height */
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700397 }
398
399 .comment-textarea {
400 width: 100%;
401 min-height: 80px;
402 padding: 8px;
403 border: 1px solid #ddd;
404 border-radius: 3px;
405 resize: vertical;
406 font-family: inherit;
407 margin-bottom: 10px;
408 box-sizing: border-box;
409 }
410
411 .comment-actions {
412 display: flex;
413 justify-content: flex-end;
414 gap: 8px;
415 }
416
417 .comment-actions button {
418 padding: 6px 12px;
419 border-radius: 3px;
420 cursor: pointer;
421 font-size: 12px;
422 }
423
424 .cancel-button {
425 background-color: transparent;
426 border: 1px solid #ddd;
427 }
428
429 .cancel-button:hover {
430 background-color: #f5f5f5;
431 }
432
433 .submit-button {
434 background-color: #4285f4;
435 color: white;
436 border: none;
437 }
438
439 .submit-button:hover {
440 background-color: #3367d6;
441 }
442
443 @keyframes fadeIn {
444 from {
445 opacity: 0;
446 }
447 to {
448 opacity: 1;
449 }
450 }
451 `;
452
453 render() {
454 return html`
455 <style>
456 ${monacoStyles}
457 </style>
458 <main ${ref(this.container)}></main>
459
460 <!-- Save indicator - shown when editing -->
Philip Zeyligere89b3082025-05-29 03:16:06 +0000461 ${this.editableRight
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700462 ? html`
463 <div class="save-indicator ${this.saveState}">
Philip Zeyligere89b3082025-05-29 03:16:06 +0000464 ${this.saveState === "idle"
465 ? "Editable"
466 : this.saveState === "modified"
467 ? "Modified..."
468 : this.saveState === "saving"
469 ? "Saving..."
470 : this.saveState === "saved"
471 ? "Saved"
472 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700473 </div>
474 `
475 : ""}
476
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700477 <!-- Comment box - shown when glyph is clicked -->
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700478 ${this.showCommentBox
479 ? html`
480 <div
481 class="comment-box"
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700482 style="top: ${this.commentBoxPosition.top}px; left: ${this
483 .commentBoxPosition.left}px;"
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700484 >
485 <div class="comment-box-header">
486 <h3>Add comment</h3>
487 <button class="close-button" @click="${this.closeCommentBox}">
488 ×
489 </button>
490 </div>
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700491 ${this.selectedLines
492 ? html`
philip.zeyliger0accea12025-07-01 09:59:18 -0700493 <div
494 class="selected-text-preview ${this.getPreviewCssClass()}"
495 >
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700496 ${this.selectedLines.text}
497 </div>
498 `
499 : ""}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700500 <textarea
501 class="comment-textarea"
502 placeholder="Type your comment here..."
503 .value="${this.commentText}"
504 @input="${this.handleCommentInput}"
505 ></textarea>
506 <div class="comment-actions">
507 <button class="cancel-button" @click="${this.closeCommentBox}">
508 Cancel
509 </button>
510 <button class="submit-button" @click="${this.submitComment}">
Josh Bleecher Snyderafeafea2025-05-23 20:27:39 +0000511 Add
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700512 </button>
513 </div>
514 </div>
515 `
516 : ""}
517 `;
518 }
519
520 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700521 * Handle changes to the comment text
522 */
523 private handleCommentInput(e: Event) {
524 const target = e.target as HTMLTextAreaElement;
525 this.commentText = target.value;
526 }
527
528 /**
philip.zeyliger0accea12025-07-01 09:59:18 -0700529 * Get CSS class for selected text preview based on number of lines
530 */
531 private getPreviewCssClass(): string {
532 if (!this.selectedLines) {
533 return "large-selection";
534 }
535
536 // Count the number of lines in the selected text
537 const lineCount = this.selectedLines.text.split("\n").length;
538
539 // If 10 lines or fewer, show all content; otherwise, limit height
540 return lineCount <= 10 ? "small-selection" : "large-selection";
541 }
542
543 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700544 * Close the comment box
545 */
546 private closeCommentBox() {
547 this.showCommentBox = false;
548 this.commentText = "";
549 this.selectedLines = null;
550 }
551
552 /**
553 * Submit the comment
554 */
555 private submitComment() {
556 try {
557 if (!this.selectedLines || !this.commentText.trim()) {
558 return;
559 }
560
561 // Store references before closing the comment box
562 const selectedLines = this.selectedLines;
563 const commentText = this.commentText;
564
565 // Get the correct filename based on active editor
566 const fileContext =
567 selectedLines.editorType === "original"
568 ? this.originalFilename || "Original file"
569 : this.modifiedFilename || "Modified file";
570
571 // Include editor info to make it clear which version was commented on
572 const editorLabel =
573 selectedLines.editorType === "original" ? "[Original]" : "[Modified]";
574
575 // Add line number information
576 let lineInfo = "";
577 if (selectedLines.startLine === selectedLines.endLine) {
578 lineInfo = ` (line ${selectedLines.startLine})`;
579 } else {
580 lineInfo = ` (lines ${selectedLines.startLine}-${selectedLines.endLine})`;
581 }
582
583 // Format the comment in a readable way
584 const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}${lineInfo}:\n${selectedLines.text}\n\`\`\`\n\n${commentText}`;
585
586 // Close UI before dispatching to prevent interaction conflicts
587 this.closeCommentBox();
588
589 // Use setTimeout to ensure the UI has updated before sending the event
590 setTimeout(() => {
591 try {
592 // Dispatch a custom event with the comment details
593 const event = new CustomEvent("monaco-comment", {
594 detail: {
595 fileContext,
596 selectedText: selectedLines.text,
597 commentText: commentText,
598 formattedComment,
599 selectionRange: {
600 startLineNumber: selectedLines.startLine,
601 startColumn: 1,
602 endLineNumber: selectedLines.endLine,
603 endColumn: 1,
604 },
605 activeEditor: selectedLines.editorType,
606 },
607 bubbles: true,
608 composed: true,
609 });
610
611 this.dispatchEvent(event);
612 } catch (error) {
613 console.error("Error dispatching comment event:", error);
614 }
615 }, 0);
616 } catch (error) {
617 console.error("Error submitting comment:", error);
618 this.closeCommentBox();
619 }
620 }
621
622 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700623 * Calculate the optimal position for the comment box to keep it in view
624 */
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700625 private calculateCommentBoxPosition(
626 lineNumber: number,
627 editorType: "original" | "modified",
628 ): { top: number; left: number } {
629 try {
630 if (!this.editor) {
631 return { top: 100, left: 100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700632 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700633
634 const targetEditor =
635 editorType === "original"
636 ? this.editor.getOriginalEditor()
637 : this.editor.getModifiedEditor();
638 if (!targetEditor) {
639 return { top: 100, left: 100 };
640 }
641
642 // Get position from editor
643 const position = {
644 lineNumber: lineNumber,
645 column: 1,
646 };
647
648 // Use editor's built-in method for coordinate conversion
649 const coords = targetEditor.getScrolledVisiblePosition(position);
650
651 if (coords) {
652 // Get accurate DOM position
653 const editorDomNode = targetEditor.getDomNode();
654 if (editorDomNode) {
655 const editorRect = editorDomNode.getBoundingClientRect();
656
657 // Calculate the actual screen position
658 let screenLeft = editorRect.left + coords.left + 20; // Offset to the right
659 let screenTop = editorRect.top + coords.top;
660
661 // Get viewport dimensions
662 const viewportWidth = window.innerWidth;
663 const viewportHeight = window.innerHeight;
664
philip.zeyliger0accea12025-07-01 09:59:18 -0700665 // Estimated box dimensions (updated for wider box)
666 const boxWidth = 600;
667 const boxHeight = 400;
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700668
669 // Check if box would go off the right edge
670 if (screenLeft + boxWidth > viewportWidth) {
671 screenLeft = viewportWidth - boxWidth - 20; // Keep 20px margin
672 }
673
674 // Check if box would go off the bottom
675 if (screenTop + boxHeight > viewportHeight) {
676 screenTop = Math.max(10, viewportHeight - boxHeight - 10);
677 }
678
679 // Ensure box is never positioned off-screen
680 screenTop = Math.max(10, screenTop);
681 screenLeft = Math.max(10, screenLeft);
682
683 return { top: screenTop, left: screenLeft };
684 }
685 }
686 } catch (error) {
687 console.error("Error calculating comment box position:", error);
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700688 }
689
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700690 return { top: 100, left: 100 };
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700691 }
692
693 setOriginalCode(code: string, filename?: string) {
694 this.originalCode = code;
695 if (filename) {
696 this.originalFilename = filename;
697 }
698
699 // Update the model if the editor is initialized
700 if (this.editor) {
701 const model = this.editor.getOriginalEditor().getModel();
702 if (model) {
703 model.setValue(code);
704 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700705 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700706 model,
707 this.getLanguageForFile(filename),
708 );
709 }
710 }
711 }
712 }
713
714 setModifiedCode(code: string, filename?: string) {
715 this.modifiedCode = code;
716 if (filename) {
717 this.modifiedFilename = filename;
718 }
719
720 // Update the model if the editor is initialized
721 if (this.editor) {
722 const model = this.editor.getModifiedEditor().getModel();
723 if (model) {
724 model.setValue(code);
725 if (filename) {
philip.zeyligerc0a44592025-06-15 21:24:57 -0700726 window.monaco!.editor.setModelLanguage(
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700727 model,
728 this.getLanguageForFile(filename),
729 );
730 }
731 }
732 }
733 }
734
Philip Zeyliger70273072025-05-28 18:26:14 +0000735 private _extensionToLanguageMap: Map<string, string> | null = null;
736
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700737 private getLanguageForFile(filename: string): string {
Philip Zeyliger70273072025-05-28 18:26:14 +0000738 // Get the file extension (including the dot for exact matching)
739 const extension = "." + (filename.split(".").pop()?.toLowerCase() || "");
740
741 // Build the extension-to-language map on first use
742 if (!this._extensionToLanguageMap) {
743 this._extensionToLanguageMap = new Map();
philip.zeyligerc0a44592025-06-15 21:24:57 -0700744 const languages = window.monaco!.languages.getLanguages();
Philip Zeyliger70273072025-05-28 18:26:14 +0000745
746 for (const language of languages) {
747 if (language.extensions) {
748 for (const ext of language.extensions) {
749 // Monaco extensions already include the dot, so use them directly
750 this._extensionToLanguageMap.set(ext.toLowerCase(), language.id);
751 }
752 }
753 }
754 }
755
756 return this._extensionToLanguageMap.get(extension) || "plaintext";
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700757 }
758
759 /**
Philip Zeyliger3cde2822025-06-21 09:32:38 -0700760 * Setup glyph decorations for both editors
761 */
762 private setupGlyphDecorations() {
763 if (!this.editor || !window.monaco) {
764 return;
765 }
766
767 const originalEditor = this.editor.getOriginalEditor();
768 const modifiedEditor = this.editor.getModifiedEditor();
769
770 if (originalEditor && this.originalModel) {
771 this.addGlyphDecorationsToEditor(
772 originalEditor,
773 this.originalModel,
774 "original",
775 );
776 this.setupHoverBehavior(originalEditor);
777 }
778
779 if (modifiedEditor && this.modifiedModel) {
780 this.addGlyphDecorationsToEditor(
781 modifiedEditor,
782 this.modifiedModel,
783 "modified",
784 );
785 this.setupHoverBehavior(modifiedEditor);
786 }
787 }
788
789 /**
790 * Add glyph decorations to a specific editor
791 */
792 private addGlyphDecorationsToEditor(
793 editor: monaco.editor.IStandaloneCodeEditor,
794 model: monaco.editor.ITextModel,
795 editorType: "original" | "modified",
796 ) {
797 if (!window.monaco) {
798 return;
799 }
800
801 // Clear existing decorations
802 if (editorType === "original" && this.originalDecorations) {
803 this.originalDecorations.clear();
804 } else if (editorType === "modified" && this.modifiedDecorations) {
805 this.modifiedDecorations.clear();
806 }
807
808 // Create decorations for every line
809 const lineCount = model.getLineCount();
810 const decorations: monaco.editor.IModelDeltaDecoration[] = [];
811
812 for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
813 decorations.push({
814 range: new window.monaco.Range(lineNumber, 1, lineNumber, 1),
815 options: {
816 isWholeLine: false,
817 glyphMarginClassName: `comment-glyph-decoration comment-glyph-${editorType}-${lineNumber}`,
818 glyphMarginHoverMessage: { value: "Comment line" },
819 stickiness:
820 window.monaco.editor.TrackedRangeStickiness
821 .NeverGrowsWhenTypingAtEdges,
822 },
823 });
824 }
825
826 // Create or update decorations collection
827 if (editorType === "original") {
828 this.originalDecorations =
829 editor.createDecorationsCollection(decorations);
830 } else {
831 this.modifiedDecorations =
832 editor.createDecorationsCollection(decorations);
833 }
834 }
835
836 /**
837 * Setup hover and click behavior for glyph decorations
838 */
839 private setupHoverBehavior(editor: monaco.editor.IStandaloneCodeEditor) {
840 if (!editor) {
841 return;
842 }
843
844 let currentHoveredLine: number | null = null;
845 const editorType =
846 this.editor?.getOriginalEditor() === editor ? "original" : "modified";
847
848 // Listen for mouse move events in the editor
849 editor.onMouseMove((e) => {
850 if (e.target.position) {
851 const lineNumber = e.target.position.lineNumber;
852
853 // Handle real-time drag preview updates
854 if (
855 this.isDragging &&
856 this.dragStartLine !== null &&
857 this.dragStartEditor === editorType &&
858 this.showCommentBox
859 ) {
860 const startLine = Math.min(this.dragStartLine, lineNumber);
861 const endLine = Math.max(this.dragStartLine, lineNumber);
862 this.updateSelectedLinesPreview(startLine, endLine, editorType);
863 }
864
865 // Handle hover glyph visibility (only when not dragging)
866 if (!this.isDragging) {
867 // If we're hovering over a different line, update visibility
868 if (currentHoveredLine !== lineNumber) {
869 // Hide previous line's glyph
870 if (currentHoveredLine !== null) {
871 this.toggleGlyphVisibility(currentHoveredLine, false);
872 }
873
874 // Show current line's glyph
875 this.toggleGlyphVisibility(lineNumber, true);
876 currentHoveredLine = lineNumber;
877 }
878 }
879 }
880 });
881
882 // Listen for mouse down events for click-to-comment and drag selection
883 editor.onMouseDown((e) => {
884 if (
885 e.target.type ===
886 window.monaco?.editor.MouseTargetType.GUTTER_GLYPH_MARGIN
887 ) {
888 if (e.target.position) {
889 const lineNumber = e.target.position.lineNumber;
890
891 // Prevent default Monaco behavior
892 e.event.preventDefault();
893 e.event.stopPropagation();
894
895 // Check if there's an existing selection in this editor
896 const selection = editor.getSelection();
897 if (selection && !selection.isEmpty()) {
898 // Use the existing selection
899 const startLine = selection.startLineNumber;
900 const endLine = selection.endLineNumber;
901 this.showCommentForSelection(
902 startLine,
903 endLine,
904 editorType,
905 selection,
906 );
907 } else {
908 // Start drag selection or show comment for clicked line
909 this.isDragging = true;
910 this.dragStartLine = lineNumber;
911 this.dragStartEditor = editorType;
912
913 // If it's just a click (not drag), show comment box immediately
914 this.showCommentForLines(lineNumber, lineNumber, editorType);
915 }
916 }
917 }
918 });
919
920 // Listen for mouse up events to end drag selection
921 editor.onMouseUp((e) => {
922 if (this.isDragging) {
923 if (
924 e.target.position &&
925 this.dragStartLine !== null &&
926 this.dragStartEditor === editorType
927 ) {
928 const endLine = e.target.position.lineNumber;
929 const startLine = Math.min(this.dragStartLine, endLine);
930 const finalEndLine = Math.max(this.dragStartLine, endLine);
931
932 // Update the final selection (if comment box is not already shown)
933 if (!this.showCommentBox) {
934 this.showCommentForLines(startLine, finalEndLine, editorType);
935 } else {
936 // Just update the final selection since preview was already being updated
937 this.updateSelectedLinesPreview(
938 startLine,
939 finalEndLine,
940 editorType,
941 );
942 }
943 }
944
945 // Reset drag state
946 this.isDragging = false;
947 this.dragStartLine = null;
948 this.dragStartEditor = null;
949 }
950 });
951
952 // // Listen for mouse leave events
953 // editor.onMouseLeave(() => {
954 // if (currentHoveredLine !== null) {
955 // this.toggleGlyphVisibility(currentHoveredLine, false);
956 // currentHoveredLine = null;
957 // }
958 // });
959 }
960
961 /**
962 * Update the selected lines preview during drag operations
963 */
964 private updateSelectedLinesPreview(
965 startLine: number,
966 endLine: number,
967 editorType: "original" | "modified",
968 ) {
969 try {
970 if (!this.editor) {
971 return;
972 }
973
974 const targetModel =
975 editorType === "original" ? this.originalModel : this.modifiedModel;
976
977 if (!targetModel) {
978 return;
979 }
980
981 // Get the text for the selected lines
982 const lines: string[] = [];
983 for (let i = startLine; i <= endLine; i++) {
984 if (i <= targetModel.getLineCount()) {
985 lines.push(targetModel.getLineContent(i));
986 }
987 }
988
989 const selectedText = lines.join("\n");
990
991 // Update the selected lines state
992 this.selectedLines = {
993 startLine,
994 endLine,
995 editorType,
996 text: selectedText,
997 };
998
999 // Request update to refresh the preview
1000 this.requestUpdate();
1001 } catch (error) {
1002 console.error("Error updating selected lines preview:", error);
1003 }
1004 }
1005
1006 /**
1007 * Show comment box for a Monaco editor selection
1008 */
1009 private showCommentForSelection(
1010 startLine: number,
1011 endLine: number,
1012 editorType: "original" | "modified",
1013 selection: monaco.Selection,
1014 ) {
1015 try {
1016 if (!this.editor) {
1017 return;
1018 }
1019
1020 const targetModel =
1021 editorType === "original" ? this.originalModel : this.modifiedModel;
1022
1023 if (!targetModel) {
1024 return;
1025 }
1026
1027 // Get the exact selected text from the Monaco selection
1028 const selectedText = targetModel.getValueInRange(selection);
1029
1030 // Set the selected lines state
1031 this.selectedLines = {
1032 startLine,
1033 endLine,
1034 editorType,
1035 text: selectedText,
1036 };
1037
1038 // Calculate and set comment box position
1039 this.commentBoxPosition = this.calculateCommentBoxPosition(
1040 startLine,
1041 editorType,
1042 );
1043
1044 // Reset comment text and show the box
1045 this.commentText = "";
1046 this.showCommentBox = true;
1047
1048 // Clear any visible glyphs since we're showing the comment box
1049 this.clearAllVisibleGlyphs();
1050
1051 // Request update to render the comment box
1052 this.requestUpdate();
1053 } catch (error) {
1054 console.error("Error showing comment for selection:", error);
1055 }
1056 }
1057
1058 /**
1059 * Show comment box for a range of lines
1060 */
1061 private showCommentForLines(
1062 startLine: number,
1063 endLine: number,
1064 editorType: "original" | "modified",
1065 ) {
1066 try {
1067 if (!this.editor) {
1068 return;
1069 }
1070
1071 const targetEditor =
1072 editorType === "original"
1073 ? this.editor.getOriginalEditor()
1074 : this.editor.getModifiedEditor();
1075 const targetModel =
1076 editorType === "original" ? this.originalModel : this.modifiedModel;
1077
1078 if (!targetEditor || !targetModel) {
1079 return;
1080 }
1081
1082 // Get the text for the selected lines
1083 const lines: string[] = [];
1084 for (let i = startLine; i <= endLine; i++) {
1085 if (i <= targetModel.getLineCount()) {
1086 lines.push(targetModel.getLineContent(i));
1087 }
1088 }
1089
1090 const selectedText = lines.join("\n");
1091
1092 // Set the selected lines state
1093 this.selectedLines = {
1094 startLine,
1095 endLine,
1096 editorType,
1097 text: selectedText,
1098 };
1099
1100 // Calculate and set comment box position
1101 this.commentBoxPosition = this.calculateCommentBoxPosition(
1102 startLine,
1103 editorType,
1104 );
1105
1106 // Reset comment text and show the box
1107 this.commentText = "";
1108 this.showCommentBox = true;
1109
1110 // Clear any visible glyphs since we're showing the comment box
1111 this.clearAllVisibleGlyphs();
1112
1113 // Request update to render the comment box
1114 this.requestUpdate();
1115 } catch (error) {
1116 console.error("Error showing comment for lines:", error);
1117 }
1118 }
1119
1120 /**
1121 * Clear all currently visible glyphs
1122 */
1123 private clearAllVisibleGlyphs() {
1124 try {
1125 this.visibleGlyphs.forEach((glyphId) => {
1126 const element = this.container.value?.querySelector(`.${glyphId}`);
1127 if (element) {
1128 element.classList.remove("hover-visible");
1129 }
1130 });
1131 this.visibleGlyphs.clear();
1132 } catch (error) {
1133 console.error("Error clearing visible glyphs:", error);
1134 }
1135 }
1136
1137 /**
1138 * Toggle the visibility of a glyph decoration for a specific line
1139 */
1140 private toggleGlyphVisibility(lineNumber: number, visible: boolean) {
1141 try {
1142 // If making visible, clear all existing visible glyphs first
1143 if (visible) {
1144 this.clearAllVisibleGlyphs();
1145 }
1146
1147 // Find all glyph decorations for this line in both editors
1148 const selectors = [
1149 `comment-glyph-original-${lineNumber}`,
1150 `comment-glyph-modified-${lineNumber}`,
1151 ];
1152
1153 selectors.forEach((glyphId) => {
1154 const element = this.container.value?.querySelector(`.${glyphId}`);
1155 if (element) {
1156 if (visible) {
1157 element.classList.add("hover-visible");
1158 this.visibleGlyphs.add(glyphId);
1159 } else {
1160 element.classList.remove("hover-visible");
1161 this.visibleGlyphs.delete(glyphId);
1162 }
1163 }
1164 });
1165 } catch (error) {
1166 console.error("Error toggling glyph visibility:", error);
1167 }
1168 }
1169
1170 /**
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001171 * Update editor options
1172 */
1173 setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
1174 if (this.editor) {
1175 this.editor.updateOptions(value);
Philip Zeyliger0635c772025-06-25 12:01:16 -07001176 // Re-fit content after options change with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001177 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001178 setTimeout(() => {
1179 // Preserve scroll positions during options change
1180 const originalScrollTop =
1181 this.editor!.getOriginalEditor().getScrollTop();
1182 const modifiedScrollTop =
1183 this.editor!.getModifiedEditor().getScrollTop();
1184
1185 this.fitEditorToContent!();
1186
1187 // Restore scroll positions
1188 requestAnimationFrame(() => {
1189 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1190 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1191 });
1192 }, 50);
David Crawshaw26f3f342025-06-14 19:58:32 +00001193 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001194 }
1195 }
1196
1197 /**
1198 * Toggle hideUnchangedRegions feature
1199 */
1200 toggleHideUnchangedRegions(enabled: boolean) {
1201 if (this.editor) {
1202 this.editor.updateOptions({
1203 hideUnchangedRegions: {
1204 enabled: enabled,
1205 contextLineCount: 3,
1206 minimumLineCount: 3,
1207 revealLineCount: 10,
1208 },
1209 });
Philip Zeyliger0635c772025-06-25 12:01:16 -07001210 // Re-fit content after toggling with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001211 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001212 setTimeout(() => {
1213 // Preserve scroll positions during toggle
1214 const originalScrollTop =
1215 this.editor!.getOriginalEditor().getScrollTop();
1216 const modifiedScrollTop =
1217 this.editor!.getModifiedEditor().getScrollTop();
1218
1219 this.fitEditorToContent!();
1220
1221 // Restore scroll positions
1222 requestAnimationFrame(() => {
1223 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1224 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1225 });
1226 }, 100);
David Crawshaw26f3f342025-06-14 19:58:32 +00001227 }
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001228 }
1229 }
1230
1231 // Models for the editor
1232 private originalModel?: monaco.editor.ITextModel;
1233 private modifiedModel?: monaco.editor.ITextModel;
1234
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001235 // Decoration collections for glyph decorations
1236 private originalDecorations?: monaco.editor.IEditorDecorationsCollection;
1237 private modifiedDecorations?: monaco.editor.IEditorDecorationsCollection;
1238
philip.zeyligerc0a44592025-06-15 21:24:57 -07001239 private async initializeEditor() {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001240 try {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001241 // Load Monaco dynamically
1242 const monaco = await loadMonaco();
Autoformatter2f8464c2025-06-16 04:27:05 +00001243
philip.zeyliger7351cd92025-06-14 12:25:31 -07001244 // Disable semantic validation globally for TypeScript/JavaScript if available
1245 if (monaco.languages && monaco.languages.typescript) {
1246 monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
1247 noSemanticValidation: true,
1248 });
1249 monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
1250 noSemanticValidation: true,
1251 });
1252 }
Autoformatter8c463622025-05-16 21:54:17 +00001253
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001254 // First time initialization
1255 if (!this.editor) {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001256 // Ensure the container ref is available
1257 if (!this.container.value) {
1258 throw new Error(
1259 "Container element not available - component may not be fully rendered",
1260 );
1261 }
1262
David Crawshaw26f3f342025-06-14 19:58:32 +00001263 // Create the diff editor with auto-sizing configuration
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001264 this.editor = monaco.editor.createDiffEditor(this.container.value, {
David Crawshaw26f3f342025-06-14 19:58:32 +00001265 automaticLayout: false, // We'll resize manually
Autoformatter8c463622025-05-16 21:54:17 +00001266 readOnly: true,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001267 theme: "vs", // Always use light mode
philip.zeyliger6b8b7662025-06-16 03:06:30 +00001268 renderSideBySide: !this.inline,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001269 ignoreTrimWhitespace: false,
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001270 // Enable glyph margin for both editors to show decorations
1271 glyphMargin: true,
David Crawshaw26f3f342025-06-14 19:58:32 +00001272 scrollbar: {
Philip Zeyligere0860932025-06-18 13:01:17 -07001273 // Ideally we'd handle the mouse wheel for the horizontal scrollbar,
1274 // but there doesn't seem to be that option. Setting
1275 // alwaysConsumeMousewheel false and handleMouseWheel true didn't
1276 // work for me.
1277 handleMouseWheel: false,
David Crawshaw26f3f342025-06-14 19:58:32 +00001278 },
Philip Zeyligere0860932025-06-18 13:01:17 -07001279 renderOverviewRuler: false, // Disable overview ruler
David Crawshaw26f3f342025-06-14 19:58:32 +00001280 scrollBeyondLastLine: false,
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001281 // Focus on the differences by hiding unchanged regions
1282 hideUnchangedRegions: {
1283 enabled: true, // Enable the feature
Philip Zeyligere0860932025-06-18 13:01:17 -07001284 contextLineCount: 5, // Show 3 lines of context around each difference
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001285 minimumLineCount: 3, // Hide regions only when they're at least 3 lines
1286 revealLineCount: 10, // Show 10 lines when expanding a hidden region
1287 },
1288 });
1289
Josh Bleecher Snyder4d90f342025-05-29 02:18:38 +00001290 this.setupKeyboardShortcuts();
1291
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001292 // If this is an editable view, set the correct read-only state for each editor
1293 if (this.editableRight) {
1294 // Make sure the original editor is always read-only
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001295 this.editor
1296 .getOriginalEditor()
1297 .updateOptions({ readOnly: true, glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001298 // Make sure the modified editor is editable
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001299 this.editor
1300 .getModifiedEditor()
1301 .updateOptions({ readOnly: false, glyphMargin: true });
1302 } else {
1303 // Ensure glyph margin is enabled on both editors even in read-only mode
1304 this.editor.getOriginalEditor().updateOptions({ glyphMargin: true });
1305 this.editor.getModifiedEditor().updateOptions({ glyphMargin: true });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001306 }
philip.zeyliger7351cd92025-06-14 12:25:31 -07001307
David Crawshaw26f3f342025-06-14 19:58:32 +00001308 // Set up auto-sizing
1309 this.setupAutoSizing();
1310
philip.zeyliger7351cd92025-06-14 12:25:31 -07001311 // Add Monaco editor to debug global
1312 this.addToDebugGlobal();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001313 }
1314
1315 // Create or update models
1316 this.updateModels();
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001317 // Add glyph decorations after models are set
1318 this.setupGlyphDecorations();
Autoformatter8c463622025-05-16 21:54:17 +00001319 // Set up content change listener
1320 this.setupContentChangeListener();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001321
philip.zeyliger7351cd92025-06-14 12:25:31 -07001322 // Fix cursor positioning issues by ensuring fonts are loaded
philip.zeyliger7351cd92025-06-14 12:25:31 -07001323 document.fonts.ready.then(() => {
1324 if (this.editor) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001325 // Preserve scroll positions during font remeasuring
1326 const originalScrollTop = this.editor
1327 .getOriginalEditor()
1328 .getScrollTop();
1329 const modifiedScrollTop = this.editor
1330 .getModifiedEditor()
1331 .getScrollTop();
1332
philip.zeyliger7351cd92025-06-14 12:25:31 -07001333 monaco.editor.remeasureFonts();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001334
1335 if (this.fitEditorToContent) {
1336 this.fitEditorToContent();
1337 }
1338
1339 // Restore scroll positions after font remeasuring
1340 requestAnimationFrame(() => {
1341 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1342 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1343 });
philip.zeyliger7351cd92025-06-14 12:25:31 -07001344 }
1345 });
1346
Philip Zeyliger0635c772025-06-25 12:01:16 -07001347 // Force layout recalculation after a short delay with scroll preservation
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001348 setTimeout(() => {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001349 if (this.editor && this.fitEditorToContent) {
1350 // Preserve scroll positions
1351 const originalScrollTop = this.editor
1352 .getOriginalEditor()
1353 .getScrollTop();
1354 const modifiedScrollTop = this.editor
1355 .getModifiedEditor()
1356 .getScrollTop();
1357
David Crawshaw26f3f342025-06-14 19:58:32 +00001358 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001359
1360 // Restore scroll positions
1361 requestAnimationFrame(() => {
1362 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1363 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1364 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001365 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001366 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001367 } catch (error) {
1368 console.error("Error initializing Monaco editor:", error);
1369 }
1370 }
1371
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001372 private updateModels() {
1373 try {
1374 // Get language based on filename
1375 const originalLang = this.getLanguageForFile(this.originalFilename || "");
1376 const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
1377
1378 // Always create new models with unique URIs based on timestamp to avoid conflicts
1379 const timestamp = new Date().getTime();
1380 // TODO: Could put filename in these URIs; unclear how they're used right now.
philip.zeyligerc0a44592025-06-15 21:24:57 -07001381 const originalUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001382 `file:///original-${timestamp}.${originalLang}`,
1383 );
philip.zeyligerc0a44592025-06-15 21:24:57 -07001384 const modifiedUri = window.monaco!.Uri.parse(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001385 `file:///modified-${timestamp}.${modifiedLang}`,
1386 );
1387
1388 // Store references to old models
1389 const oldOriginalModel = this.originalModel;
1390 const oldModifiedModel = this.modifiedModel;
1391
1392 // Nullify instance variables to prevent accidental use
1393 this.originalModel = undefined;
1394 this.modifiedModel = undefined;
1395
1396 // Clear the editor model first to release Monaco's internal references
1397 if (this.editor) {
1398 this.editor.setModel(null);
1399 }
1400
1401 // Now it's safe to dispose the old models
1402 if (oldOriginalModel) {
1403 oldOriginalModel.dispose();
1404 }
1405
1406 if (oldModifiedModel) {
1407 oldModifiedModel.dispose();
1408 }
1409
1410 // Create new models
philip.zeyligerc0a44592025-06-15 21:24:57 -07001411 this.originalModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001412 this.originalCode || "",
1413 originalLang,
1414 originalUri,
1415 );
1416
philip.zeyligerc0a44592025-06-15 21:24:57 -07001417 this.modifiedModel = window.monaco!.editor.createModel(
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001418 this.modifiedCode || "",
1419 modifiedLang,
1420 modifiedUri,
1421 );
1422
1423 // Set the new models on the editor
1424 if (this.editor) {
1425 this.editor.setModel({
1426 original: this.originalModel,
1427 modified: this.modifiedModel,
1428 });
Autoformatter9abf8032025-06-14 23:24:08 +00001429
David Crawshaw26f3f342025-06-14 19:58:32 +00001430 // Set initial hideUnchangedRegions state (default to enabled/collapsed)
1431 this.editor.updateOptions({
1432 hideUnchangedRegions: {
1433 enabled: true, // Default to collapsed state
1434 contextLineCount: 3,
1435 minimumLineCount: 3,
1436 revealLineCount: 10,
1437 },
1438 });
Autoformatter9abf8032025-06-14 23:24:08 +00001439
Philip Zeyliger0635c772025-06-25 12:01:16 -07001440 // Fit content after setting new models with scroll preservation
David Crawshaw26f3f342025-06-14 19:58:32 +00001441 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001442 setTimeout(() => {
1443 // Preserve scroll positions when fitting content after model changes
1444 const originalScrollTop =
1445 this.editor!.getOriginalEditor().getScrollTop();
1446 const modifiedScrollTop =
1447 this.editor!.getModifiedEditor().getScrollTop();
1448
1449 this.fitEditorToContent!();
1450
1451 // Restore scroll positions
1452 requestAnimationFrame(() => {
1453 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1454 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1455 });
1456 }, 50);
David Crawshaw26f3f342025-06-14 19:58:32 +00001457 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001458
1459 // Add glyph decorations after setting new models
1460 setTimeout(() => this.setupGlyphDecorations(), 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001461 }
1462 this.setupContentChangeListener();
1463 } catch (error) {
1464 console.error("Error updating Monaco models:", error);
1465 }
1466 }
1467
philip.zeyligerc0a44592025-06-15 21:24:57 -07001468 async updated(changedProperties: Map<string, any>) {
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001469 // If any relevant properties changed, just update the models
1470 if (
1471 changedProperties.has("originalCode") ||
1472 changedProperties.has("modifiedCode") ||
1473 changedProperties.has("originalFilename") ||
1474 changedProperties.has("modifiedFilename") ||
1475 changedProperties.has("editableRight")
1476 ) {
1477 if (this.editor) {
1478 this.updateModels();
1479
David Crawshaw26f3f342025-06-14 19:58:32 +00001480 // Force auto-sizing after model updates
Philip Zeyliger0635c772025-06-25 12:01:16 -07001481 // Use a slightly longer delay to ensure layout is stable with scroll preservation
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001482 setTimeout(() => {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001483 if (this.fitEditorToContent && this.editor) {
1484 // Preserve scroll positions during model update layout
1485 const originalScrollTop = this.editor
1486 .getOriginalEditor()
1487 .getScrollTop();
1488 const modifiedScrollTop = this.editor
1489 .getModifiedEditor()
1490 .getScrollTop();
1491
David Crawshaw26f3f342025-06-14 19:58:32 +00001492 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001493
1494 // Restore scroll positions
1495 requestAnimationFrame(() => {
1496 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1497 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1498 });
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001499 }
David Crawshaw26f3f342025-06-14 19:58:32 +00001500 }, 100);
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001501 } else {
1502 // If the editor isn't initialized yet but we received content,
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001503 // ensure we're connected before initializing
1504 await this.ensureConnectedToDocument();
philip.zeyligerc0a44592025-06-15 21:24:57 -07001505 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001506 }
1507 }
1508 }
1509
David Crawshaw26f3f342025-06-14 19:58:32 +00001510 // Set up auto-sizing for multi-file diff view
1511 private setupAutoSizing() {
1512 if (!this.editor) return;
1513
1514 const fitContent = () => {
1515 try {
1516 const originalEditor = this.editor!.getOriginalEditor();
1517 const modifiedEditor = this.editor!.getModifiedEditor();
Autoformatter9abf8032025-06-14 23:24:08 +00001518
David Crawshaw26f3f342025-06-14 19:58:32 +00001519 const originalHeight = originalEditor.getContentHeight();
1520 const modifiedHeight = modifiedEditor.getContentHeight();
Autoformatter9abf8032025-06-14 23:24:08 +00001521
David Crawshaw26f3f342025-06-14 19:58:32 +00001522 // Use the maximum height of both editors, plus some padding
1523 const maxHeight = Math.max(originalHeight, modifiedHeight) + 18; // 1 blank line bottom padding
Autoformatter9abf8032025-06-14 23:24:08 +00001524
David Crawshaw26f3f342025-06-14 19:58:32 +00001525 // Set both container and host height to enable proper scrolling
1526 if (this.container.value) {
1527 // Set explicit heights on both container and host
1528 this.container.value.style.height = `${maxHeight}px`;
1529 this.style.height = `${maxHeight}px`; // Update host element height
Autoformatter9abf8032025-06-14 23:24:08 +00001530
David Crawshaw26f3f342025-06-14 19:58:32 +00001531 // Emit the height change event BEFORE calling layout
1532 // This ensures parent containers resize first
Autoformatter9abf8032025-06-14 23:24:08 +00001533 this.dispatchEvent(
1534 new CustomEvent("monaco-height-changed", {
1535 detail: { height: maxHeight },
1536 bubbles: true,
1537 composed: true,
1538 }),
1539 );
1540
David Crawshaw26f3f342025-06-14 19:58:32 +00001541 // Layout after both this component and parents have updated
1542 setTimeout(() => {
1543 if (this.editor && this.container.value) {
1544 // Use explicit dimensions to ensure Monaco uses full available space
David Crawshawdba26b52025-06-15 00:33:45 +00001545 // Use clientWidth instead of offsetWidth to avoid border overflow
1546 const width = this.container.value.clientWidth;
David Crawshaw26f3f342025-06-14 19:58:32 +00001547 this.editor.layout({
1548 width: width,
Autoformatter9abf8032025-06-14 23:24:08 +00001549 height: maxHeight,
David Crawshaw26f3f342025-06-14 19:58:32 +00001550 });
1551 }
1552 }, 10);
1553 }
1554 } catch (error) {
Autoformatter9abf8032025-06-14 23:24:08 +00001555 console.error("Error in fitContent:", error);
David Crawshaw26f3f342025-06-14 19:58:32 +00001556 }
1557 };
1558
1559 // Store the fit function for external access
1560 this.fitEditorToContent = fitContent;
1561
1562 // Set up listeners for content size changes
1563 this.editor.getOriginalEditor().onDidContentSizeChange(fitContent);
1564 this.editor.getModifiedEditor().onDidContentSizeChange(fitContent);
1565
1566 // Initial fit
1567 fitContent();
1568 }
1569
1570 private fitEditorToContent: (() => void) | null = null;
1571
David Crawshawe2954ce2025-06-15 00:06:34 +00001572 /**
1573 * Set up window resize handler to ensure Monaco editor adapts to browser window changes
1574 */
1575 private setupWindowResizeHandler() {
1576 // Create a debounced resize handler to avoid too many layout calls
1577 let resizeTimeout: number | null = null;
Autoformatterad15b6c2025-06-15 00:29:26 +00001578
David Crawshawe2954ce2025-06-15 00:06:34 +00001579 this._windowResizeHandler = () => {
1580 // Clear any existing timeout
1581 if (resizeTimeout) {
1582 window.clearTimeout(resizeTimeout);
1583 }
Autoformatterad15b6c2025-06-15 00:29:26 +00001584
David Crawshawe2954ce2025-06-15 00:06:34 +00001585 // Debounce the resize to avoid excessive layout calls
1586 resizeTimeout = window.setTimeout(() => {
1587 if (this.editor && this.container.value) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001588 // Trigger layout recalculation with scroll preservation
David Crawshawe2954ce2025-06-15 00:06:34 +00001589 if (this.fitEditorToContent) {
Philip Zeyliger0635c772025-06-25 12:01:16 -07001590 // Preserve scroll positions during window resize
1591 const originalScrollTop = this.editor
1592 .getOriginalEditor()
1593 .getScrollTop();
1594 const modifiedScrollTop = this.editor
1595 .getModifiedEditor()
1596 .getScrollTop();
1597
David Crawshawe2954ce2025-06-15 00:06:34 +00001598 this.fitEditorToContent();
Philip Zeyliger0635c772025-06-25 12:01:16 -07001599
1600 // Restore scroll positions
1601 requestAnimationFrame(() => {
1602 this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
1603 this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
1604 });
David Crawshawe2954ce2025-06-15 00:06:34 +00001605 } else {
1606 // Fallback: just trigger a layout with current container dimensions
David Crawshawdba26b52025-06-15 00:33:45 +00001607 // Use clientWidth/Height instead of offsetWidth/Height to avoid border overflow
1608 const width = this.container.value.clientWidth;
1609 const height = this.container.value.clientHeight;
David Crawshawe2954ce2025-06-15 00:06:34 +00001610 this.editor.layout({ width, height });
1611 }
1612 }
1613 }, 100); // 100ms debounce
1614 };
Autoformatterad15b6c2025-06-15 00:29:26 +00001615
David Crawshawe2954ce2025-06-15 00:06:34 +00001616 // Add the event listener
Autoformatterad15b6c2025-06-15 00:29:26 +00001617 window.addEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001618 }
1619
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001620 // Add resize observer to ensure editor resizes when container changes
philip.zeyligerc0a44592025-06-15 21:24:57 -07001621 async firstUpdated() {
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001622 // Ensure we're connected to the document before Monaco initialization
1623 await this.ensureConnectedToDocument();
1624
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001625 // Initialize the editor
philip.zeyligerc0a44592025-06-15 21:24:57 -07001626 await this.initializeEditor();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001627
David Crawshawe2954ce2025-06-15 00:06:34 +00001628 // Set up window resize handler to ensure Monaco editor adapts to browser window changes
1629 this.setupWindowResizeHandler();
1630
David Crawshaw26f3f342025-06-14 19:58:32 +00001631 // For multi-file diff, we don't use ResizeObserver since we control the size
1632 // Instead, we rely on auto-sizing based on content
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001633
1634 // If editable, set up edit mode and content change listener
1635 if (this.editableRight && this.editor) {
1636 // Ensure the original editor is read-only
1637 this.editor.getOriginalEditor().updateOptions({ readOnly: true });
1638 // Ensure the modified editor is editable
1639 this.editor.getModifiedEditor().updateOptions({ readOnly: false });
1640 }
1641 }
1642
Philip Zeyliger1f8fe9c2025-06-20 02:56:28 +00001643 /**
1644 * Ensure this component and its container are properly connected to the document.
1645 * Monaco editor requires the container to be in the document for proper initialization.
1646 */
1647 private async ensureConnectedToDocument(): Promise<void> {
1648 // Wait for our own render to complete
1649 await this.updateComplete;
1650
1651 // Verify the container ref is available
1652 if (!this.container.value) {
1653 throw new Error("Container element not available after updateComplete");
1654 }
1655
1656 // Check if we're connected to the document
1657 if (!this.isConnected) {
1658 throw new Error("Component is not connected to the document");
1659 }
1660
1661 // Verify the container is also in the document
1662 if (!this.container.value.isConnected) {
1663 throw new Error("Container element is not connected to the document");
1664 }
1665 }
1666
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001667 private _resizeObserver: ResizeObserver | null = null;
David Crawshawe2954ce2025-06-15 00:06:34 +00001668 private _windowResizeHandler: (() => void) | null = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001669
philip.zeyliger7351cd92025-06-14 12:25:31 -07001670 /**
1671 * Add this Monaco editor instance to the global debug object
1672 * This allows inspection and debugging via browser console
1673 */
1674 private addToDebugGlobal() {
1675 try {
1676 // Initialize the debug global if it doesn't exist
1677 if (!(window as any).sketchDebug) {
1678 (window as any).sketchDebug = {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001679 monaco: window.monaco!,
philip.zeyliger7351cd92025-06-14 12:25:31 -07001680 editors: [],
1681 remeasureFonts: () => {
philip.zeyligerc0a44592025-06-15 21:24:57 -07001682 window.monaco!.editor.remeasureFonts();
philip.zeyliger7351cd92025-06-14 12:25:31 -07001683 (window as any).sketchDebug.editors.forEach(
philip.zeyliger26bc6592025-06-30 20:15:30 -07001684 (editor: any, _index: number) => {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001685 if (editor && editor.layout) {
1686 editor.layout();
1687 }
1688 },
1689 );
1690 },
1691 layoutAll: () => {
1692 (window as any).sketchDebug.editors.forEach(
philip.zeyliger26bc6592025-06-30 20:15:30 -07001693 (editor: any, _index: number) => {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001694 if (editor && editor.layout) {
1695 editor.layout();
1696 }
1697 },
1698 );
1699 },
1700 getActiveEditors: () => {
1701 return (window as any).sketchDebug.editors.filter(
1702 (editor: any) => editor !== null,
1703 );
1704 },
1705 };
1706 }
1707
1708 // Add this editor to the debug collection
1709 if (this.editor) {
1710 (window as any).sketchDebug.editors.push(this.editor);
1711 }
1712 } catch (error) {
1713 console.error("Error adding Monaco editor to debug global:", error);
1714 }
1715 }
1716
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001717 disconnectedCallback() {
1718 super.disconnectedCallback();
1719
1720 try {
philip.zeyliger7351cd92025-06-14 12:25:31 -07001721 // Remove editor from debug global before disposal
1722 if (this.editor && (window as any).sketchDebug?.editors) {
1723 const index = (window as any).sketchDebug.editors.indexOf(this.editor);
1724 if (index > -1) {
1725 (window as any).sketchDebug.editors[index] = null;
1726 }
1727 }
1728
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001729 // Clean up decorations
1730 if (this.originalDecorations) {
1731 this.originalDecorations.clear();
1732 this.originalDecorations = undefined;
1733 }
1734
1735 if (this.modifiedDecorations) {
1736 this.modifiedDecorations.clear();
1737 this.modifiedDecorations = undefined;
1738 }
1739
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001740 // Clean up resources when element is removed
1741 if (this.editor) {
1742 this.editor.dispose();
1743 this.editor = undefined;
1744 }
1745
1746 // Dispose models to prevent memory leaks
1747 if (this.originalModel) {
1748 this.originalModel.dispose();
1749 this.originalModel = undefined;
1750 }
1751
1752 if (this.modifiedModel) {
1753 this.modifiedModel.dispose();
1754 this.modifiedModel = undefined;
1755 }
1756
David Crawshaw26f3f342025-06-14 19:58:32 +00001757 // Clean up resize observer (if any)
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001758 if (this._resizeObserver) {
1759 this._resizeObserver.disconnect();
1760 this._resizeObserver = null;
1761 }
Autoformatter9abf8032025-06-14 23:24:08 +00001762
David Crawshaw26f3f342025-06-14 19:58:32 +00001763 // Clear the fit function reference
1764 this.fitEditorToContent = null;
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001765
David Crawshawe2954ce2025-06-15 00:06:34 +00001766 // Remove window resize handler if set
1767 if (this._windowResizeHandler) {
Autoformatterad15b6c2025-06-15 00:29:26 +00001768 window.removeEventListener("resize", this._windowResizeHandler);
David Crawshawe2954ce2025-06-15 00:06:34 +00001769 this._windowResizeHandler = null;
1770 }
Philip Zeyliger3cde2822025-06-21 09:32:38 -07001771
1772 // Clear visible glyphs tracking
1773 this.visibleGlyphs.clear();
Philip Zeyliger272a90e2025-05-16 14:49:51 -07001774 } catch (error) {
1775 console.error("Error in disconnectedCallback:", error);
1776 }
1777 }
1778
1779 // disconnectedCallback implementation is defined below
1780}
1781
1782declare global {
1783 interface HTMLElementTagNameMap {
1784 "sketch-monaco-view": CodeDiffEditor;
1785 }
1786}