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