blob: 10995b2136efb4c3eadb6c41b192e3b0c7a60e8b [file] [log] [blame]
/* eslint-disable no-async-promise-executor, @typescript-eslint/ban-ts-comment */
import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, Ref, ref } from "lit/directives/ref.js";
import { SketchTailwindElement } from "./sketch-tailwind-element.js";
// See https://rodydavis.com/posts/lit-monaco-editor for some ideas.
import type * as monaco from "monaco-editor";
// Monaco is loaded dynamically - see loadMonaco() function
declare global {
interface Window {
monaco?: typeof monaco;
}
}
// Monaco hash will be injected at build time
declare const __MONACO_HASH__: string;
// Load Monaco editor dynamically
let monacoLoadPromise: Promise<any> | null = null;
function loadMonaco(): Promise<typeof monaco> {
if (monacoLoadPromise) {
return monacoLoadPromise;
}
if (window.monaco) {
return Promise.resolve(window.monaco);
}
monacoLoadPromise = new Promise(async (resolve, reject) => {
try {
// Check if we're in development mode
const isDev = __MONACO_HASH__ === "dev";
if (isDev) {
// In development mode, import Monaco directly
const monaco = await import("monaco-editor");
window.monaco = monaco;
resolve(monaco);
} else {
// In production mode, load from external bundle
const monacoHash = __MONACO_HASH__;
// Try to load the external Monaco bundle
const script = document.createElement("script");
script.onload = () => {
// The Monaco bundle should set window.monaco
if (window.monaco) {
resolve(window.monaco);
} else {
reject(new Error("Monaco not loaded from external bundle"));
}
};
script.onerror = (error) => {
console.warn("Failed to load external Monaco bundle:", error);
reject(new Error("Monaco external bundle failed to load"));
};
// Don't set type="module" since we're using IIFE format
script.src = `./static/monaco-standalone-${monacoHash}.js`;
document.head.appendChild(script);
}
} catch (error) {
reject(error);
}
});
return monacoLoadPromise;
}
// Define Monaco CSS styles as a string constant
const monacoStyles = `
/* Import Monaco editor styles */
@import url('./static/monaco/min/vs/editor/editor.main.css');
/* Codicon font is now defined globally in sketch-app-shell.css */
/* Custom Monaco styles */
.monaco-editor {
width: 100%;
height: 100%;
}
/* Ensure light theme colors */
.monaco-editor, .monaco-editor-background, .monaco-editor .inputarea.ime-input {
background-color: var(--monaco-editor-bg, #ffffff) !important;
}
.monaco-editor .margin {
background-color: var(--monaco-editor-margin, #f5f5f5) !important;
}
/* Glyph decoration styles - only show on hover */
.comment-glyph-decoration {
width: 16px !important;
height: 18px !important;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
}
.comment-glyph-decoration:before {
content: '💬';
font-size: 12px;
line-height: 18px;
width: 16px;
height: 18px;
display: block;
text-align: center;
}
.comment-glyph-decoration.hover-visible {
opacity: 1;
}
`;
// Configure Monaco to use local workers with correct relative paths
// Monaco looks for this global configuration to determine how to load web workers
// @ts-ignore - MonacoEnvironment is added to the global scope at runtime
self.MonacoEnvironment = {
getWorkerUrl: function (_moduleId, label) {
if (label === "json") {
return "./static/json.worker.js";
}
if (label === "css" || label === "scss" || label === "less") {
return "./static/css.worker.js";
}
if (label === "html" || label === "handlebars" || label === "razor") {
return "./static/html.worker.js";
}
if (label === "typescript" || label === "javascript") {
return "./static/ts.worker.js";
}
return "./static/editor.worker.js";
},
};
@customElement("sketch-monaco-view")
export class CodeDiffEditor extends SketchTailwindElement {
// Editable state
@property({ type: Boolean, attribute: "editable-right" })
editableRight?: boolean;
// Inline diff mode (for mobile)
@property({ type: Boolean, attribute: "inline" })
inline?: boolean;
private container: Ref<HTMLElement> = createRef();
editor?: monaco.editor.IStandaloneDiffEditor;
// Save state properties
@state() private saveState: "idle" | "modified" | "saving" | "saved" = "idle";
@state() private debounceSaveTimeout: number | null = null;
@state() private lastSavedContent: string = "";
@property() originalCode?: string = "// Original code here";
@property() modifiedCode?: string = "// Modified code here";
@property() originalFilename?: string = "original.js";
@property() modifiedFilename?: string = "modified.js";
// Comment system state
@state() private showCommentBox: boolean = false;
@state() private commentText: string = "";
@state() private selectedLines: {
startLine: number;
endLine: number;
editorType: "original" | "modified";
text: string;
} | null = null;
@state() private commentBoxPosition: { top: number; left: number } = {
top: 0,
left: 0,
};
@state() private isDragging: boolean = false;
@state() private dragStartLine: number | null = null;
@state() private dragStartEditor: "original" | "modified" | null = null;
// Track visible glyphs to ensure proper cleanup
private visibleGlyphs: Set<string> = new Set();
// Custom event to request save action from external components
private requestSave() {
if (!this.editableRight || this.saveState !== "modified") return;
this.saveState = "saving";
// Get current content from modified editor
const modifiedContent = this.modifiedModel?.getValue() || "";
// Create and dispatch the save event
const saveEvent = new CustomEvent("monaco-save", {
detail: {
path: this.modifiedFilename,
content: modifiedContent,
},
bubbles: true,
composed: true,
});
this.dispatchEvent(saveEvent);
}
// Method to be called from parent when save is complete
public notifySaveComplete(success: boolean) {
if (success) {
this.saveState = "saved";
// Update last saved content
this.lastSavedContent = this.modifiedModel?.getValue() || "";
// Reset to idle after a delay
setTimeout(() => {
this.saveState = "idle";
}, 2000);
} else {
// Return to modified state on error
this.saveState = "modified";
}
}
// Rescue people with strong save-constantly habits
private setupKeyboardShortcuts() {
if (!this.editor) return;
const modifiedEditor = this.editor.getModifiedEditor();
if (!modifiedEditor) return;
const monaco = window.monaco;
if (!monaco) return;
modifiedEditor.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
() => {
this.requestSave();
},
);
}
// Setup content change listener for debounced save
private setupContentChangeListener() {
if (!this.editor || !this.editableRight) return;
const modifiedEditor = this.editor.getModifiedEditor();
if (!modifiedEditor || !modifiedEditor.getModel()) return;
// Store initial content
this.lastSavedContent = modifiedEditor.getModel()!.getValue();
// Listen for content changes
modifiedEditor.getModel()!.onDidChangeContent(() => {
const currentContent = modifiedEditor.getModel()!.getValue();
// Check if content has actually changed from last saved state
if (currentContent !== this.lastSavedContent) {
this.saveState = "modified";
// Debounce save request
if (this.debounceSaveTimeout) {
window.clearTimeout(this.debounceSaveTimeout);
}
this.debounceSaveTimeout = window.setTimeout(() => {
this.requestSave();
this.debounceSaveTimeout = null;
}, 1000); // 1 second debounce
}
// Update glyph decorations when content changes
setTimeout(() => {
if (this.editor && this.modifiedModel) {
this.addGlyphDecorationsToEditor(
this.editor.getModifiedEditor(),
this.modifiedModel,
"modified",
);
}
}, 50);
});
}
render() {
// Set host element styles for layout (equivalent to :host styles)
this.style.cssText = `
--editor-width: 100%;
--editor-height: 100%;
display: flex;
flex: none;
min-height: 0;
position: relative;
width: 100%;
`;
return html`
<style>
${monacoStyles}
/* Custom animation for comment box fade-in */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
}
</style>
<main
${ref(this.container)}
class="w-full h-full border border-gray-300 flex-none min-h-[200px] relative block box-border"
></main>
<!-- Save indicator - shown when editing -->
${this.editableRight
? html`
<div
class="absolute top-1 right-1 px-2 py-0.5 rounded text-xs font-sans text-white z-[100] opacity-90 pointer-events-none transition-opacity duration-300 ${this
.saveState === "idle"
? "bg-gray-500"
: this.saveState === "modified"
? "bg-yellow-500"
: this.saveState === "saving"
? "bg-blue-400"
: this.saveState === "saved"
? "bg-green-500"
: "bg-gray-500"}"
>
${this.saveState === "idle"
? "Editable"
: this.saveState === "modified"
? "Modified..."
: this.saveState === "saving"
? "Saving..."
: this.saveState === "saved"
? "Saved"
: ""}
</div>
`
: ""}
<!-- Comment box - shown when glyph is clicked -->
${this.showCommentBox
? html`
<div
class="fixed bg-white border border-gray-300 rounded shadow-lg p-3 z-[10001] w-[600px] animate-fade-in max-h-[80vh] overflow-y-auto"
style="top: ${this.commentBoxPosition.top}px; left: ${this
.commentBoxPosition.left}px;"
>
<div class="flex justify-between items-center mb-2">
<h3 class="m-0 text-sm font-medium">Add comment</h3>
<button
class="bg-none border-none cursor-pointer text-base text-gray-600 px-1.5 py-0.5 hover:text-gray-800"
@click="${this.closeCommentBox}"
>
×
</button>
</div>
${this.selectedLines
? html`
<div
class="bg-gray-100 border border-gray-200 rounded p-2 mb-2.5 font-mono text-xs overflow-y-auto whitespace-pre-wrap break-all leading-relaxed ${this.getPreviewCssClass() ===
"small-selection"
? ""
: "max-h-[280px]"}"
>
${this.selectedLines.text}
</div>
`
: ""}
<textarea
class="w-full min-h-[80px] p-2 border border-gray-300 rounded resize-y font-inherit mb-2.5 box-border"
placeholder="Type your comment here..."
.value="${this.commentText}"
@input="${this.handleCommentInput}"
@keydown="${this.handleCommentKeydown}"
></textarea>
<div class="flex justify-end gap-2">
<button
class="px-3 py-1.5 rounded cursor-pointer text-xs bg-transparent border border-gray-300 hover:bg-gray-100"
@click="${this.closeCommentBox}"
>
Cancel
</button>
<button
class="px-3 py-1.5 rounded cursor-pointer text-xs bg-blue-600 text-white border-none hover:bg-blue-700"
@click="${this.submitComment}"
>
Add
</button>
</div>
</div>
`
: ""}
`;
}
/**
* Handle changes to the comment text
*/
private handleCommentInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
this.commentText = target.value;
}
/**
* Handle keyboard shortcuts in the comment textarea
*/
private handleCommentKeydown(e: KeyboardEvent) {
// Check for Command+Enter (Mac) or Ctrl+Enter (other platforms)
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.submitComment();
}
}
/**
* Get CSS class for selected text preview based on number of lines
*/
private getPreviewCssClass(): string {
if (!this.selectedLines) {
return "large-selection";
}
// Count the number of lines in the selected text
const lineCount = this.selectedLines.text.split("\n").length;
// If 10 lines or fewer, show all content; otherwise, limit height
return lineCount <= 10 ? "small-selection" : "large-selection";
}
/**
* Close the comment box
*/
private closeCommentBox() {
this.showCommentBox = false;
this.commentText = "";
this.selectedLines = null;
}
/**
* Submit the comment
*/
private submitComment() {
try {
if (!this.selectedLines || !this.commentText.trim()) {
return;
}
// Store references before closing the comment box
const selectedLines = this.selectedLines;
const commentText = this.commentText;
// Get the correct filename based on active editor
const fileContext =
selectedLines.editorType === "original"
? this.originalFilename || "Original file"
: this.modifiedFilename || "Modified file";
// Include editor info to make it clear which version was commented on
const editorLabel =
selectedLines.editorType === "original" ? "[Original]" : "[Modified]";
// Add line number information
let lineInfo = "";
if (selectedLines.startLine === selectedLines.endLine) {
lineInfo = ` (line ${selectedLines.startLine})`;
} else {
lineInfo = ` (lines ${selectedLines.startLine}-${selectedLines.endLine})`;
}
// Format the comment in a readable way
const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}${lineInfo}:\n${selectedLines.text}\n\`\`\`\n\n${commentText}`;
// Close UI before dispatching to prevent interaction conflicts
this.closeCommentBox();
// Use setTimeout to ensure the UI has updated before sending the event
setTimeout(() => {
try {
// Dispatch a custom event with the comment details
const event = new CustomEvent("monaco-comment", {
detail: {
fileContext,
selectedText: selectedLines.text,
commentText: commentText,
formattedComment,
selectionRange: {
startLineNumber: selectedLines.startLine,
startColumn: 1,
endLineNumber: selectedLines.endLine,
endColumn: 1,
},
activeEditor: selectedLines.editorType,
},
bubbles: true,
composed: true,
});
this.dispatchEvent(event);
} catch (error) {
console.error("Error dispatching comment event:", error);
}
}, 0);
} catch (error) {
console.error("Error submitting comment:", error);
this.closeCommentBox();
}
}
/**
* Calculate the optimal position for the comment box to keep it in view
*/
private calculateCommentBoxPosition(
lineNumber: number,
editorType: "original" | "modified",
): { top: number; left: number } {
try {
if (!this.editor) {
return { top: 100, left: 100 };
}
const targetEditor =
editorType === "original"
? this.editor.getOriginalEditor()
: this.editor.getModifiedEditor();
if (!targetEditor) {
return { top: 100, left: 100 };
}
// Get position from editor
const position = {
lineNumber: lineNumber,
column: 1,
};
// Use editor's built-in method for coordinate conversion
const coords = targetEditor.getScrolledVisiblePosition(position);
if (coords) {
// Get accurate DOM position
const editorDomNode = targetEditor.getDomNode();
if (editorDomNode) {
const editorRect = editorDomNode.getBoundingClientRect();
// Calculate the actual screen position
let screenLeft = editorRect.left + coords.left + 20; // Offset to the right
let screenTop = editorRect.top + coords.top;
// Get viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Estimated box dimensions (updated for wider box)
const boxWidth = 600;
const boxHeight = 400;
// Check if box would go off the right edge
if (screenLeft + boxWidth > viewportWidth) {
screenLeft = viewportWidth - boxWidth - 20; // Keep 20px margin
}
// Check if box would go off the bottom
if (screenTop + boxHeight > viewportHeight) {
screenTop = Math.max(10, viewportHeight - boxHeight - 10);
}
// Ensure box is never positioned off-screen
screenTop = Math.max(10, screenTop);
screenLeft = Math.max(10, screenLeft);
return { top: screenTop, left: screenLeft };
}
}
} catch (error) {
console.error("Error calculating comment box position:", error);
}
return { top: 100, left: 100 };
}
setOriginalCode(code: string, filename?: string) {
this.originalCode = code;
if (filename) {
this.originalFilename = filename;
}
// Update the model if the editor is initialized
if (this.editor) {
const model = this.editor.getOriginalEditor().getModel();
if (model) {
model.setValue(code);
if (filename) {
window.monaco!.editor.setModelLanguage(
model,
this.getLanguageForFile(filename),
);
}
}
}
}
setModifiedCode(code: string, filename?: string) {
this.modifiedCode = code;
if (filename) {
this.modifiedFilename = filename;
}
// Update the model if the editor is initialized
if (this.editor) {
const model = this.editor.getModifiedEditor().getModel();
if (model) {
model.setValue(code);
if (filename) {
window.monaco!.editor.setModelLanguage(
model,
this.getLanguageForFile(filename),
);
}
}
}
}
private _extensionToLanguageMap: Map<string, string> | null = null;
private getLanguageForFile(filename: string): string {
// Get the file extension (including the dot for exact matching)
const extension = "." + (filename.split(".").pop()?.toLowerCase() || "");
// Build the extension-to-language map on first use
if (!this._extensionToLanguageMap) {
this._extensionToLanguageMap = new Map();
const languages = window.monaco!.languages.getLanguages();
for (const language of languages) {
if (language.extensions) {
for (const ext of language.extensions) {
// Monaco extensions already include the dot, so use them directly
this._extensionToLanguageMap.set(ext.toLowerCase(), language.id);
}
}
}
}
return this._extensionToLanguageMap.get(extension) || "plaintext";
}
/**
* Setup glyph decorations for both editors
*/
private setupGlyphDecorations() {
if (!this.editor || !window.monaco) {
return;
}
const originalEditor = this.editor.getOriginalEditor();
const modifiedEditor = this.editor.getModifiedEditor();
if (originalEditor && this.originalModel) {
this.addGlyphDecorationsToEditor(
originalEditor,
this.originalModel,
"original",
);
this.setupHoverBehavior(originalEditor);
}
if (modifiedEditor && this.modifiedModel) {
this.addGlyphDecorationsToEditor(
modifiedEditor,
this.modifiedModel,
"modified",
);
this.setupHoverBehavior(modifiedEditor);
}
}
/**
* Add glyph decorations to a specific editor
*/
private addGlyphDecorationsToEditor(
editor: monaco.editor.IStandaloneCodeEditor,
model: monaco.editor.ITextModel,
editorType: "original" | "modified",
) {
if (!window.monaco) {
return;
}
// Clear existing decorations
if (editorType === "original" && this.originalDecorations) {
this.originalDecorations.clear();
} else if (editorType === "modified" && this.modifiedDecorations) {
this.modifiedDecorations.clear();
}
// Create decorations for every line
const lineCount = model.getLineCount();
const decorations: monaco.editor.IModelDeltaDecoration[] = [];
for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
decorations.push({
range: new window.monaco.Range(lineNumber, 1, lineNumber, 1),
options: {
isWholeLine: false,
glyphMarginClassName: `comment-glyph-decoration comment-glyph-${editorType}-${lineNumber}`,
glyphMarginHoverMessage: { value: "Comment line" },
stickiness:
window.monaco.editor.TrackedRangeStickiness
.NeverGrowsWhenTypingAtEdges,
},
});
}
// Create or update decorations collection
if (editorType === "original") {
this.originalDecorations =
editor.createDecorationsCollection(decorations);
} else {
this.modifiedDecorations =
editor.createDecorationsCollection(decorations);
}
}
/**
* Setup hover and click behavior for glyph decorations
*/
private setupHoverBehavior(editor: monaco.editor.IStandaloneCodeEditor) {
if (!editor) {
return;
}
let currentHoveredLine: number | null = null;
const editorType =
this.editor?.getOriginalEditor() === editor ? "original" : "modified";
// Listen for mouse move events in the editor
editor.onMouseMove((e) => {
if (e.target.position) {
const lineNumber = e.target.position.lineNumber;
// Handle real-time drag preview updates
if (
this.isDragging &&
this.dragStartLine !== null &&
this.dragStartEditor === editorType &&
this.showCommentBox
) {
const startLine = Math.min(this.dragStartLine, lineNumber);
const endLine = Math.max(this.dragStartLine, lineNumber);
this.updateSelectedLinesPreview(startLine, endLine, editorType);
}
// Handle hover glyph visibility (only when not dragging)
if (!this.isDragging) {
// If we're hovering over a different line, update visibility
if (currentHoveredLine !== lineNumber) {
// Hide previous line's glyph
if (currentHoveredLine !== null) {
this.toggleGlyphVisibility(currentHoveredLine, false);
}
// Show current line's glyph
this.toggleGlyphVisibility(lineNumber, true);
currentHoveredLine = lineNumber;
}
}
}
});
// Listen for mouse down events for click-to-comment and drag selection
editor.onMouseDown((e) => {
if (
e.target.type ===
window.monaco?.editor.MouseTargetType.GUTTER_GLYPH_MARGIN
) {
if (e.target.position) {
const lineNumber = e.target.position.lineNumber;
// Prevent default Monaco behavior
e.event.preventDefault();
e.event.stopPropagation();
// Check if there's an existing selection in this editor
const selection = editor.getSelection();
if (selection && !selection.isEmpty()) {
// Use the existing selection
const startLine = selection.startLineNumber;
const endLine = selection.endLineNumber;
this.showCommentForSelection(
startLine,
endLine,
editorType,
selection,
);
} else {
// Start drag selection or show comment for clicked line
this.isDragging = true;
this.dragStartLine = lineNumber;
this.dragStartEditor = editorType;
// If it's just a click (not drag), show comment box immediately
this.showCommentForLines(lineNumber, lineNumber, editorType);
}
}
}
});
// Listen for mouse up events to end drag selection
editor.onMouseUp((e) => {
if (this.isDragging) {
if (
e.target.position &&
this.dragStartLine !== null &&
this.dragStartEditor === editorType
) {
const endLine = e.target.position.lineNumber;
const startLine = Math.min(this.dragStartLine, endLine);
const finalEndLine = Math.max(this.dragStartLine, endLine);
// Update the final selection (if comment box is not already shown)
if (!this.showCommentBox) {
this.showCommentForLines(startLine, finalEndLine, editorType);
} else {
// Just update the final selection since preview was already being updated
this.updateSelectedLinesPreview(
startLine,
finalEndLine,
editorType,
);
}
}
// Reset drag state
this.isDragging = false;
this.dragStartLine = null;
this.dragStartEditor = null;
}
});
// // Listen for mouse leave events
// editor.onMouseLeave(() => {
// if (currentHoveredLine !== null) {
// this.toggleGlyphVisibility(currentHoveredLine, false);
// currentHoveredLine = null;
// }
// });
}
/**
* Update the selected lines preview during drag operations
*/
private updateSelectedLinesPreview(
startLine: number,
endLine: number,
editorType: "original" | "modified",
) {
try {
if (!this.editor) {
return;
}
const targetModel =
editorType === "original" ? this.originalModel : this.modifiedModel;
if (!targetModel) {
return;
}
// Get the text for the selected lines
const lines: string[] = [];
for (let i = startLine; i <= endLine; i++) {
if (i <= targetModel.getLineCount()) {
lines.push(targetModel.getLineContent(i));
}
}
const selectedText = lines.join("\n");
// Update the selected lines state
this.selectedLines = {
startLine,
endLine,
editorType,
text: selectedText,
};
// Request update to refresh the preview
this.requestUpdate();
} catch (error) {
console.error("Error updating selected lines preview:", error);
}
}
/**
* Show comment box for a Monaco editor selection
*/
private showCommentForSelection(
startLine: number,
endLine: number,
editorType: "original" | "modified",
selection: monaco.Selection,
) {
try {
if (!this.editor) {
return;
}
const targetModel =
editorType === "original" ? this.originalModel : this.modifiedModel;
if (!targetModel) {
return;
}
// Get the exact selected text from the Monaco selection
const selectedText = targetModel.getValueInRange(selection);
// Set the selected lines state
this.selectedLines = {
startLine,
endLine,
editorType,
text: selectedText,
};
// Calculate and set comment box position
this.commentBoxPosition = this.calculateCommentBoxPosition(
startLine,
editorType,
);
// Reset comment text and show the box
this.commentText = "";
this.showCommentBox = true;
// Clear any visible glyphs since we're showing the comment box
this.clearAllVisibleGlyphs();
// Request update to render the comment box
this.requestUpdate();
} catch (error) {
console.error("Error showing comment for selection:", error);
}
}
/**
* Show comment box for a range of lines
*/
private showCommentForLines(
startLine: number,
endLine: number,
editorType: "original" | "modified",
) {
try {
if (!this.editor) {
return;
}
const targetEditor =
editorType === "original"
? this.editor.getOriginalEditor()
: this.editor.getModifiedEditor();
const targetModel =
editorType === "original" ? this.originalModel : this.modifiedModel;
if (!targetEditor || !targetModel) {
return;
}
// Get the text for the selected lines
const lines: string[] = [];
for (let i = startLine; i <= endLine; i++) {
if (i <= targetModel.getLineCount()) {
lines.push(targetModel.getLineContent(i));
}
}
const selectedText = lines.join("\n");
// Set the selected lines state
this.selectedLines = {
startLine,
endLine,
editorType,
text: selectedText,
};
// Calculate and set comment box position
this.commentBoxPosition = this.calculateCommentBoxPosition(
startLine,
editorType,
);
// Reset comment text and show the box
this.commentText = "";
this.showCommentBox = true;
// Clear any visible glyphs since we're showing the comment box
this.clearAllVisibleGlyphs();
// Request update to render the comment box
this.requestUpdate();
} catch (error) {
console.error("Error showing comment for lines:", error);
}
}
/**
* Clear all currently visible glyphs
*/
private clearAllVisibleGlyphs() {
try {
this.visibleGlyphs.forEach((glyphId) => {
const element = this.container.value?.querySelector(`.${glyphId}`);
if (element) {
element.classList.remove("hover-visible");
}
});
this.visibleGlyphs.clear();
} catch (error) {
console.error("Error clearing visible glyphs:", error);
}
}
/**
* Toggle the visibility of a glyph decoration for a specific line
*/
private toggleGlyphVisibility(lineNumber: number, visible: boolean) {
try {
// If making visible, clear all existing visible glyphs first
if (visible) {
this.clearAllVisibleGlyphs();
}
// Find all glyph decorations for this line in both editors
const selectors = [
`comment-glyph-original-${lineNumber}`,
`comment-glyph-modified-${lineNumber}`,
];
selectors.forEach((glyphId) => {
const element = this.container.value?.querySelector(`.${glyphId}`);
if (element) {
if (visible) {
element.classList.add("hover-visible");
this.visibleGlyphs.add(glyphId);
} else {
element.classList.remove("hover-visible");
this.visibleGlyphs.delete(glyphId);
}
}
});
} catch (error) {
console.error("Error toggling glyph visibility:", error);
}
}
/**
* Update editor options
*/
setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
if (this.editor) {
this.editor.updateOptions(value);
// Re-fit content after options change with scroll preservation
if (this.fitEditorToContent) {
setTimeout(() => {
// Preserve scroll positions during options change
const originalScrollTop =
this.editor!.getOriginalEditor().getScrollTop();
const modifiedScrollTop =
this.editor!.getModifiedEditor().getScrollTop();
this.fitEditorToContent!();
// Restore scroll positions
requestAnimationFrame(() => {
this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
});
}, 50);
}
}
}
/**
* Toggle hideUnchangedRegions feature
*/
toggleHideUnchangedRegions(enabled: boolean) {
if (this.editor) {
this.editor.updateOptions({
hideUnchangedRegions: {
enabled: enabled,
contextLineCount: 3,
minimumLineCount: 3,
revealLineCount: 10,
},
});
// Re-fit content after toggling with scroll preservation
if (this.fitEditorToContent) {
setTimeout(() => {
// Preserve scroll positions during toggle
const originalScrollTop =
this.editor!.getOriginalEditor().getScrollTop();
const modifiedScrollTop =
this.editor!.getModifiedEditor().getScrollTop();
this.fitEditorToContent!();
// Restore scroll positions
requestAnimationFrame(() => {
this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
});
}, 100);
}
}
}
// Models for the editor
private originalModel?: monaco.editor.ITextModel;
private modifiedModel?: monaco.editor.ITextModel;
// Decoration collections for glyph decorations
private originalDecorations?: monaco.editor.IEditorDecorationsCollection;
private modifiedDecorations?: monaco.editor.IEditorDecorationsCollection;
private async initializeEditor() {
try {
// Load Monaco dynamically
const monaco = await loadMonaco();
// Disable semantic validation globally for TypeScript/JavaScript if available
if (monaco.languages && monaco.languages.typescript) {
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
});
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
});
}
// First time initialization
if (!this.editor) {
// Ensure the container ref is available
if (!this.container.value) {
throw new Error(
"Container element not available - component may not be fully rendered",
);
}
// Create the diff editor with auto-sizing configuration
this.editor = monaco.editor.createDiffEditor(this.container.value, {
automaticLayout: false, // We'll resize manually
readOnly: true,
theme: "vs", // Always use light mode
renderSideBySide: !this.inline,
ignoreTrimWhitespace: false,
// Enable glyph margin for both editors to show decorations
glyphMargin: true,
scrollbar: {
// Ideally we'd handle the mouse wheel for the horizontal scrollbar,
// but there doesn't seem to be that option. Setting
// alwaysConsumeMousewheel false and handleMouseWheel true didn't
// work for me.
handleMouseWheel: false,
},
renderOverviewRuler: false, // Disable overview ruler
scrollBeyondLastLine: false,
// Focus on the differences by hiding unchanged regions
hideUnchangedRegions: {
enabled: true, // Enable the feature
contextLineCount: 5, // Show 3 lines of context around each difference
minimumLineCount: 3, // Hide regions only when they're at least 3 lines
revealLineCount: 10, // Show 10 lines when expanding a hidden region
},
});
this.setupKeyboardShortcuts();
// If this is an editable view, set the correct read-only state for each editor
if (this.editableRight) {
// Make sure the original editor is always read-only
this.editor
.getOriginalEditor()
.updateOptions({ readOnly: true, glyphMargin: true });
// Make sure the modified editor is editable
this.editor
.getModifiedEditor()
.updateOptions({ readOnly: false, glyphMargin: true });
} else {
// Ensure glyph margin is enabled on both editors even in read-only mode
this.editor.getOriginalEditor().updateOptions({ glyphMargin: true });
this.editor.getModifiedEditor().updateOptions({ glyphMargin: true });
}
// Set up auto-sizing
this.setupAutoSizing();
// Add Monaco editor to debug global
this.addToDebugGlobal();
}
// Create or update models
this.updateModels();
// Add glyph decorations after models are set
this.setupGlyphDecorations();
// Set up content change listener
this.setupContentChangeListener();
// Fix cursor positioning issues by ensuring fonts are loaded
document.fonts.ready.then(() => {
if (this.editor) {
// Preserve scroll positions during font remeasuring
const originalScrollTop = this.editor
.getOriginalEditor()
.getScrollTop();
const modifiedScrollTop = this.editor
.getModifiedEditor()
.getScrollTop();
monaco.editor.remeasureFonts();
if (this.fitEditorToContent) {
this.fitEditorToContent();
}
// Restore scroll positions after font remeasuring
requestAnimationFrame(() => {
this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
});
}
});
// Force layout recalculation after a short delay with scroll preservation
setTimeout(() => {
if (this.editor && this.fitEditorToContent) {
// Preserve scroll positions
const originalScrollTop = this.editor
.getOriginalEditor()
.getScrollTop();
const modifiedScrollTop = this.editor
.getModifiedEditor()
.getScrollTop();
this.fitEditorToContent();
// Restore scroll positions
requestAnimationFrame(() => {
this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
});
}
}, 100);
} catch (error) {
console.error("Error initializing Monaco editor:", error);
}
}
private updateModels() {
try {
// Get language based on filename
const originalLang = this.getLanguageForFile(this.originalFilename || "");
const modifiedLang = this.getLanguageForFile(this.modifiedFilename || "");
// Always create new models with unique URIs based on timestamp to avoid conflicts
const timestamp = new Date().getTime();
// TODO: Could put filename in these URIs; unclear how they're used right now.
const originalUri = window.monaco!.Uri.parse(
`file:///original-${timestamp}.${originalLang}`,
);
const modifiedUri = window.monaco!.Uri.parse(
`file:///modified-${timestamp}.${modifiedLang}`,
);
// Store references to old models
const oldOriginalModel = this.originalModel;
const oldModifiedModel = this.modifiedModel;
// Nullify instance variables to prevent accidental use
this.originalModel = undefined;
this.modifiedModel = undefined;
// Clear the editor model first to release Monaco's internal references
if (this.editor) {
this.editor.setModel(null);
}
// Now it's safe to dispose the old models
if (oldOriginalModel) {
oldOriginalModel.dispose();
}
if (oldModifiedModel) {
oldModifiedModel.dispose();
}
// Create new models
this.originalModel = window.monaco!.editor.createModel(
this.originalCode || "",
originalLang,
originalUri,
);
this.modifiedModel = window.monaco!.editor.createModel(
this.modifiedCode || "",
modifiedLang,
modifiedUri,
);
// Set the new models on the editor
if (this.editor) {
this.editor.setModel({
original: this.originalModel,
modified: this.modifiedModel,
});
// Set initial hideUnchangedRegions state (default to enabled/collapsed)
this.editor.updateOptions({
hideUnchangedRegions: {
enabled: true, // Default to collapsed state
contextLineCount: 3,
minimumLineCount: 3,
revealLineCount: 10,
},
});
// Fit content after setting new models with scroll preservation
if (this.fitEditorToContent) {
setTimeout(() => {
// Preserve scroll positions when fitting content after model changes
const originalScrollTop =
this.editor!.getOriginalEditor().getScrollTop();
const modifiedScrollTop =
this.editor!.getModifiedEditor().getScrollTop();
this.fitEditorToContent!();
// Restore scroll positions
requestAnimationFrame(() => {
this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
});
}, 50);
}
// Add glyph decorations after setting new models
setTimeout(() => this.setupGlyphDecorations(), 100);
}
this.setupContentChangeListener();
} catch (error) {
console.error("Error updating Monaco models:", error);
}
}
async updated(changedProperties: Map<string, any>) {
// If any relevant properties changed, just update the models
if (
changedProperties.has("originalCode") ||
changedProperties.has("modifiedCode") ||
changedProperties.has("originalFilename") ||
changedProperties.has("modifiedFilename") ||
changedProperties.has("editableRight")
) {
if (this.editor) {
this.updateModels();
// Force auto-sizing after model updates
// Use a slightly longer delay to ensure layout is stable with scroll preservation
setTimeout(() => {
if (this.fitEditorToContent && this.editor) {
// Preserve scroll positions during model update layout
const originalScrollTop = this.editor
.getOriginalEditor()
.getScrollTop();
const modifiedScrollTop = this.editor
.getModifiedEditor()
.getScrollTop();
this.fitEditorToContent();
// Restore scroll positions
requestAnimationFrame(() => {
this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
});
}
}, 100);
} else {
// If the editor isn't initialized yet but we received content,
// ensure we're connected before initializing
await this.ensureConnectedToDocument();
await this.initializeEditor();
}
}
}
// Set up auto-sizing for multi-file diff view
private setupAutoSizing() {
if (!this.editor) return;
const fitContent = () => {
try {
const originalEditor = this.editor!.getOriginalEditor();
const modifiedEditor = this.editor!.getModifiedEditor();
const originalHeight = originalEditor.getContentHeight();
const modifiedHeight = modifiedEditor.getContentHeight();
// Use the maximum height of both editors, plus some padding
const maxHeight = Math.max(originalHeight, modifiedHeight) + 18; // 1 blank line bottom padding
// Set both container and host height to enable proper scrolling
if (this.container.value) {
// Set explicit heights on both container and host
this.container.value.style.height = `${maxHeight}px`;
this.style.height = `${maxHeight}px`; // Update host element height
// Emit the height change event BEFORE calling layout
// This ensures parent containers resize first
this.dispatchEvent(
new CustomEvent("monaco-height-changed", {
detail: { height: maxHeight },
bubbles: true,
composed: true,
}),
);
// Layout after both this component and parents have updated
setTimeout(() => {
if (this.editor && this.container.value) {
// Use explicit dimensions to ensure Monaco uses full available space
// Use clientWidth instead of offsetWidth to avoid border overflow
const width = this.container.value.clientWidth;
this.editor.layout({
width: width,
height: maxHeight,
});
}
}, 10);
}
} catch (error) {
console.error("Error in fitContent:", error);
}
};
// Store the fit function for external access
this.fitEditorToContent = fitContent;
// Set up listeners for content size changes
this.editor.getOriginalEditor().onDidContentSizeChange(fitContent);
this.editor.getModifiedEditor().onDidContentSizeChange(fitContent);
// Initial fit
fitContent();
}
private fitEditorToContent: (() => void) | null = null;
/**
* Set up window resize handler to ensure Monaco editor adapts to browser window changes
*/
private setupWindowResizeHandler() {
// Create a debounced resize handler to avoid too many layout calls
let resizeTimeout: number | null = null;
this._windowResizeHandler = () => {
// Clear any existing timeout
if (resizeTimeout) {
window.clearTimeout(resizeTimeout);
}
// Debounce the resize to avoid excessive layout calls
resizeTimeout = window.setTimeout(() => {
if (this.editor && this.container.value) {
// Trigger layout recalculation with scroll preservation
if (this.fitEditorToContent) {
// Preserve scroll positions during window resize
const originalScrollTop = this.editor
.getOriginalEditor()
.getScrollTop();
const modifiedScrollTop = this.editor
.getModifiedEditor()
.getScrollTop();
this.fitEditorToContent();
// Restore scroll positions
requestAnimationFrame(() => {
this.editor!.getOriginalEditor().setScrollTop(originalScrollTop);
this.editor!.getModifiedEditor().setScrollTop(modifiedScrollTop);
});
} else {
// Fallback: just trigger a layout with current container dimensions
// Use clientWidth/Height instead of offsetWidth/Height to avoid border overflow
const width = this.container.value.clientWidth;
const height = this.container.value.clientHeight;
this.editor.layout({ width, height });
}
}
}, 100); // 100ms debounce
};
// Add the event listener
window.addEventListener("resize", this._windowResizeHandler);
}
// Add resize observer to ensure editor resizes when container changes
async firstUpdated() {
// Ensure we're connected to the document before Monaco initialization
await this.ensureConnectedToDocument();
// Initialize the editor
await this.initializeEditor();
// Set up window resize handler to ensure Monaco editor adapts to browser window changes
this.setupWindowResizeHandler();
// For multi-file diff, we don't use ResizeObserver since we control the size
// Instead, we rely on auto-sizing based on content
// If editable, set up edit mode and content change listener
if (this.editableRight && this.editor) {
// Ensure the original editor is read-only
this.editor.getOriginalEditor().updateOptions({ readOnly: true });
// Ensure the modified editor is editable
this.editor.getModifiedEditor().updateOptions({ readOnly: false });
}
}
/**
* Ensure this component and its container are properly connected to the document.
* Monaco editor requires the container to be in the document for proper initialization.
*/
private async ensureConnectedToDocument(): Promise<void> {
// Wait for our own render to complete
await this.updateComplete;
// Verify the container ref is available
if (!this.container.value) {
throw new Error("Container element not available after updateComplete");
}
// Check if we're connected to the document
if (!this.isConnected) {
throw new Error("Component is not connected to the document");
}
// Verify the container is also in the document
if (!this.container.value.isConnected) {
throw new Error("Container element is not connected to the document");
}
}
private _resizeObserver: ResizeObserver | null = null;
private _windowResizeHandler: (() => void) | null = null;
/**
* Add this Monaco editor instance to the global debug object
* This allows inspection and debugging via browser console
*/
private addToDebugGlobal() {
try {
// Initialize the debug global if it doesn't exist
if (!(window as any).sketchDebug) {
(window as any).sketchDebug = {
monaco: window.monaco!,
editors: [],
remeasureFonts: () => {
window.monaco!.editor.remeasureFonts();
(window as any).sketchDebug.editors.forEach(
(editor: any, _index: number) => {
if (editor && editor.layout) {
editor.layout();
}
},
);
},
layoutAll: () => {
(window as any).sketchDebug.editors.forEach(
(editor: any, _index: number) => {
if (editor && editor.layout) {
editor.layout();
}
},
);
},
getActiveEditors: () => {
return (window as any).sketchDebug.editors.filter(
(editor: any) => editor !== null,
);
},
};
}
// Add this editor to the debug collection
if (this.editor) {
(window as any).sketchDebug.editors.push(this.editor);
}
} catch (error) {
console.error("Error adding Monaco editor to debug global:", error);
}
}
disconnectedCallback() {
super.disconnectedCallback();
try {
// Remove editor from debug global before disposal
if (this.editor && (window as any).sketchDebug?.editors) {
const index = (window as any).sketchDebug.editors.indexOf(this.editor);
if (index > -1) {
(window as any).sketchDebug.editors[index] = null;
}
}
// Clean up decorations
if (this.originalDecorations) {
this.originalDecorations.clear();
this.originalDecorations = undefined;
}
if (this.modifiedDecorations) {
this.modifiedDecorations.clear();
this.modifiedDecorations = undefined;
}
// Clean up resources when element is removed
if (this.editor) {
this.editor.dispose();
this.editor = undefined;
}
// Dispose models to prevent memory leaks
if (this.originalModel) {
this.originalModel.dispose();
this.originalModel = undefined;
}
if (this.modifiedModel) {
this.modifiedModel.dispose();
this.modifiedModel = undefined;
}
// Clean up resize observer (if any)
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = null;
}
// Clear the fit function reference
this.fitEditorToContent = null;
// Remove window resize handler if set
if (this._windowResizeHandler) {
window.removeEventListener("resize", this._windowResizeHandler);
this._windowResizeHandler = null;
}
// Clear visible glyphs tracking
this.visibleGlyphs.clear();
} catch (error) {
console.error("Error in disconnectedCallback:", error);
}
}
// disconnectedCallback implementation is defined below
}
declare global {
interface HTMLElementTagNameMap {
"sketch-monaco-view": CodeDiffEditor;
}
}