blob: 1ffb7e26a8e0ea84b7437c397d96b78820cb33b0 [file] [log] [blame]
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, Ref, ref } from "lit/directives/ref.js";
// See https://rodydavis.com/posts/lit-monaco-editor for some ideas.
import * as monaco from "monaco-editor";
// Configure Monaco to use local workers with correct relative paths
// 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%;
}
/* Custom font stack - ensure we have good monospace fonts */
.monaco-editor .view-lines,
.monaco-editor .view-line,
.monaco-editor-pane,
.monaco-editor .inputarea {
font-family: "Menlo", "Monaco", "Consolas", "Courier New", monospace !important;
font-size: 13px !important;
font-feature-settings: "liga" 0, "calt" 0 !important;
line-height: 1.5 !important;
}
/* 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;
}
`;
// 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 LitElement {
// Editable state
@property({ type: Boolean, attribute: "editable-right" })
editableRight?: 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";
/* Selected text and indicators */
@state()
private selectedText: string | null = null;
@state()
private selectionRange: {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
} | null = null;
@state()
private showCommentIndicator: boolean = false;
@state()
private indicatorPosition: { top: number; left: number } = {
top: 0,
left: 0,
};
@state()
private showCommentBox: boolean = false;
@state()
private commentText: string = "";
@state()
private activeEditor: "original" | "modified" = "modified"; // Track which editor is active
// 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;
modifiedEditor.addCommand(
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS,
() => {
this.requestSave();
},
);
console.log("Keyboard shortcuts set up for Monaco editor");
}
// 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
}
});
}
static styles = css`
/* Save indicator styles */
.save-indicator {
position: absolute;
top: 4px;
right: 4px;
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-family: system-ui, sans-serif;
color: white;
z-index: 100;
opacity: 0.9;
pointer-events: none;
transition: opacity 0.3s ease;
}
.save-indicator.idle {
background-color: #6c757d;
}
.save-indicator.modified {
background-color: #f0ad4e;
}
.save-indicator.saving {
background-color: #5bc0de;
}
.save-indicator.saved {
background-color: #5cb85c;
}
/* Editor host styles */
:host {
--editor-width: 100%;
--editor-height: 100%;
display: flex;
flex: 1;
min-height: 0; /* Critical for flex layout */
position: relative; /* Establish positioning context */
height: 100%; /* Take full height */
width: 100%; /* Take full width */
}
main {
width: var(--editor-width);
height: var(--editor-height);
border: 1px solid #e0e0e0;
flex: 1;
min-height: 300px; /* Ensure a minimum height for the editor */
position: absolute; /* Absolute positioning to take full space */
top: 0;
left: 0;
right: 0;
bottom: 0;
}
/* Comment indicator and box styles */
.comment-indicator {
position: fixed;
background-color: rgba(66, 133, 244, 0.9);
color: white;
border-radius: 3px;
padding: 3px 8px;
font-size: 12px;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
z-index: 10000;
animation: fadeIn 0.2s ease-in-out;
display: flex;
align-items: center;
gap: 4px;
pointer-events: all;
}
.comment-indicator:hover {
background-color: rgba(66, 133, 244, 1);
}
.comment-indicator span {
line-height: 1;
}
.comment-box {
position: fixed;
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15);
padding: 12px;
z-index: 10001;
width: 350px;
animation: fadeIn 0.2s ease-in-out;
max-height: 80vh;
overflow-y: auto;
}
.comment-box-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.comment-box-header h3 {
margin: 0;
font-size: 14px;
font-weight: 500;
}
.close-button {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #666;
padding: 2px 6px;
}
.close-button:hover {
color: #333;
}
.selected-text-preview {
background-color: #f5f5f5;
border: 1px solid #eee;
border-radius: 3px;
padding: 8px;
margin-bottom: 10px;
font-family: monospace;
font-size: 12px;
max-height: 80px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.comment-textarea {
width: 100%;
min-height: 80px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 3px;
resize: vertical;
font-family: inherit;
margin-bottom: 10px;
box-sizing: border-box;
}
.comment-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.comment-actions button {
padding: 6px 12px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
}
.cancel-button {
background-color: transparent;
border: 1px solid #ddd;
}
.cancel-button:hover {
background-color: #f5f5f5;
}
.submit-button {
background-color: #4285f4;
color: white;
border: none;
}
.submit-button:hover {
background-color: #3367d6;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
render() {
return html`
<style>
${monacoStyles}
</style>
<main ${ref(this.container)}></main>
<!-- Save indicator - shown when editing -->
${this.editableRight
? html`
<div class="save-indicator ${this.saveState}">
${this.saveState === "idle"
? "Editable"
: this.saveState === "modified"
? "Modified..."
: this.saveState === "saving"
? "Saving..."
: this.saveState === "saved"
? "Saved"
: ""}
</div>
`
: ""}
<!-- Comment indicator - shown when text is selected -->
${this.showCommentIndicator
? html`
<div
class="comment-indicator"
style="top: ${this.indicatorPosition.top}px; left: ${this
.indicatorPosition.left}px;"
@click="${this.handleIndicatorClick}"
@mouseenter="${() => {
this._isHovering = true;
}}"
@mouseleave="${() => {
this._isHovering = false;
}}"
>
<span>💬</span>
<span>Add comment</span>
</div>
`
: ""}
<!-- Comment box - shown when indicator is clicked -->
${this.showCommentBox
? html`
<div
class="comment-box"
style="${this.calculateCommentBoxPosition()}"
@mouseenter="${() => {
this._isHovering = true;
}}"
@mouseleave="${() => {
this._isHovering = false;
}}"
>
<div class="comment-box-header">
<h3>Add comment</h3>
<button class="close-button" @click="${this.closeCommentBox}">
×
</button>
</div>
<div class="selected-text-preview">${this.selectedText}</div>
<textarea
class="comment-textarea"
placeholder="Type your comment here..."
.value="${this.commentText}"
@input="${this.handleCommentInput}"
></textarea>
<div class="comment-actions">
<button class="cancel-button" @click="${this.closeCommentBox}">
Cancel
</button>
<button class="submit-button" @click="${this.submitComment}">
Add
</button>
</div>
</div>
`
: ""}
`;
}
/**
* Calculate the optimal position for the comment box to keep it in view
*/
private calculateCommentBoxPosition(): string {
// Get viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Default position (below indicator)
let top = this.indicatorPosition.top + 30;
let left = this.indicatorPosition.left;
// Estimated box dimensions
const boxWidth = 350;
const boxHeight = 300;
// Check if box would go off the right edge
if (left + boxWidth > viewportWidth) {
left = viewportWidth - boxWidth - 20; // Keep 20px margin
}
// Check if box would go off the bottom
const bottomSpace = viewportHeight - top;
if (bottomSpace < boxHeight) {
// Not enough space below, try to position above if possible
if (this.indicatorPosition.top > boxHeight) {
// Position above the indicator
top = this.indicatorPosition.top - boxHeight - 10;
} else {
// Not enough space above either, position at top of viewport with margin
top = 10;
}
}
// Ensure box is never positioned off-screen
top = Math.max(10, top);
left = Math.max(10, left);
return `top: ${top}px; left: ${left}px;`;
}
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) {
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) {
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 = 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";
}
/**
* Update editor options
*/
setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
if (this.editor) {
this.editor.updateOptions(value);
}
}
/**
* Toggle hideUnchangedRegions feature
*/
toggleHideUnchangedRegions(enabled: boolean) {
if (this.editor) {
this.editor.updateOptions({
hideUnchangedRegions: {
enabled: enabled,
contextLineCount: 3,
minimumLineCount: 3,
revealLineCount: 10,
},
});
}
}
// Models for the editor
private originalModel?: monaco.editor.ITextModel;
private modifiedModel?: monaco.editor.ITextModel;
private initializeEditor() {
try {
// Disable semantic validation globally for TypeScript/JavaScript
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
});
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
});
// First time initialization
if (!this.editor) {
// Create the diff editor only once
this.editor = monaco.editor.createDiffEditor(this.container.value!, {
automaticLayout: true,
// Make it read-only by default
// We'll adjust individual editor settings after creation
readOnly: true,
theme: "vs", // Always use light mode
renderSideBySide: true,
ignoreTrimWhitespace: false,
// Focus on the differences by hiding unchanged regions
hideUnchangedRegions: {
enabled: true, // Enable the feature
contextLineCount: 3, // 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
},
});
console.log("Monaco diff editor created");
// Set up selection change event listeners for both editors
this.setupSelectionChangeListeners();
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 });
// Make sure the modified editor is editable
this.editor.getModifiedEditor().updateOptions({ readOnly: false });
}
}
// Create or update models
this.updateModels();
// Set up content change listener
this.setupContentChangeListener();
// Force layout recalculation after a short delay
// This ensures the editor renders properly, especially with single files
setTimeout(() => {
if (this.editor) {
this.editor.layout();
console.log("Monaco diff editor layout updated");
}
}, 50);
console.log("Monaco diff editor initialized");
} catch (error) {
console.error("Error initializing Monaco editor:", error);
}
}
/**
* Sets up event listeners for text selection in both editors.
* This enables showing the comment UI when users select text and
* manages the visibility of UI components based on user interactions.
*/
private setupSelectionChangeListeners() {
try {
if (!this.editor) {
console.log("Editor not available for setting up listeners");
return;
}
// Get both original and modified editors
const originalEditor = this.editor.getOriginalEditor();
const modifiedEditor = this.editor.getModifiedEditor();
if (!originalEditor || !modifiedEditor) {
console.log("Original or modified editor not available");
return;
}
// Add selection change listener to original editor
originalEditor.onDidChangeCursorSelection((e) => {
this.handleSelectionChange(e, originalEditor, "original");
});
// Add selection change listener to modified editor
modifiedEditor.onDidChangeCursorSelection((e) => {
this.handleSelectionChange(e, modifiedEditor, "modified");
});
// Create a debounced function for mouse move handling
let mouseMoveTimeout: number | null = null;
const handleMouseMove = () => {
// Clear any existing timeout
if (mouseMoveTimeout) {
window.clearTimeout(mouseMoveTimeout);
}
// If there's text selected and we're not showing the comment box, keep indicator visible
if (this.selectedText && !this.showCommentBox) {
this.showCommentIndicator = true;
this.requestUpdate();
}
// Set a new timeout to hide the indicator after a delay
mouseMoveTimeout = window.setTimeout(() => {
// Only hide if we're not showing the comment box and not actively hovering
if (!this.showCommentBox && !this._isHovering) {
this.showCommentIndicator = false;
this.requestUpdate();
}
}, 2000); // Hide after 2 seconds of inactivity
};
// Add mouse move listeners with debouncing
originalEditor.onMouseMove(() => handleMouseMove());
modifiedEditor.onMouseMove(() => handleMouseMove());
// Track hover state over the indicator and comment box
this._isHovering = false;
// Use the global document click handler to detect clicks outside
this._documentClickHandler = (e: MouseEvent) => {
try {
const target = e.target as HTMLElement;
const isIndicator =
target.matches(".comment-indicator") ||
!!target.closest(".comment-indicator");
const isCommentBox =
target.matches(".comment-box") || !!target.closest(".comment-box");
// If click is outside our UI elements
if (!isIndicator && !isCommentBox) {
// If we're not showing the comment box, hide the indicator
if (!this.showCommentBox) {
this.showCommentIndicator = false;
this.requestUpdate();
}
}
} catch (error) {
console.error("Error in document click handler:", error);
}
};
// Add the document click listener
document.addEventListener("click", this._documentClickHandler);
console.log("Selection change listeners set up successfully");
} catch (error) {
console.error("Error setting up selection listeners:", error);
}
}
// Track mouse hover state
private _isHovering = false;
// Store document click handler for cleanup
private _documentClickHandler: ((e: MouseEvent) => void) | null = null;
/**
* Handle selection change events from either editor
*/
private handleSelectionChange(
e: monaco.editor.ICursorSelectionChangedEvent,
editor: monaco.editor.IStandaloneCodeEditor,
editorType: "original" | "modified",
) {
try {
// If we're not making a selection (just moving cursor), do nothing
if (e.selection.isEmpty()) {
// Don't hide indicator or box if already shown
if (!this.showCommentBox) {
this.selectedText = null;
this.selectionRange = null;
this.showCommentIndicator = false;
}
return;
}
// Get selected text
const model = editor.getModel();
if (!model) {
console.log("No model available for selection");
return;
}
// Make sure selection is within valid range
const lineCount = model.getLineCount();
if (
e.selection.startLineNumber > lineCount ||
e.selection.endLineNumber > lineCount
) {
console.log("Selection out of bounds");
return;
}
// Store which editor is active
this.activeEditor = editorType;
// Store selection range
this.selectionRange = {
startLineNumber: e.selection.startLineNumber,
startColumn: e.selection.startColumn,
endLineNumber: e.selection.endLineNumber,
endColumn: e.selection.endColumn,
};
try {
// Expand selection to full lines for better context
const expandedSelection = {
startLineNumber: e.selection.startLineNumber,
startColumn: 1, // Start at beginning of line
endLineNumber: e.selection.endLineNumber,
endColumn: model.getLineMaxColumn(e.selection.endLineNumber), // End at end of line
};
// Get the selected text using the expanded selection
this.selectedText = model.getValueInRange(expandedSelection);
// Update the selection range to reflect the full lines
this.selectionRange = {
startLineNumber: expandedSelection.startLineNumber,
startColumn: expandedSelection.startColumn,
endLineNumber: expandedSelection.endLineNumber,
endColumn: expandedSelection.endColumn,
};
} catch (error) {
console.error("Error getting selected text:", error);
return;
}
// If there's selected text, show the indicator
if (this.selectedText && this.selectedText.trim() !== "") {
// Calculate indicator position safely
try {
// Use the editor's DOM node as positioning context
const editorDomNode = editor.getDomNode();
if (!editorDomNode) {
console.log("No editor DOM node available");
return;
}
// Get position from editor
const position = {
lineNumber: e.selection.endLineNumber,
column: e.selection.endColumn,
};
// Use editor's built-in method for coordinate conversion
const selectionCoords = editor.getScrolledVisiblePosition(position);
if (selectionCoords) {
// Get accurate DOM position for the selection end
const editorRect = editorDomNode.getBoundingClientRect();
// Calculate the actual screen position
const screenLeft = editorRect.left + selectionCoords.left;
const screenTop = editorRect.top + selectionCoords.top;
// Store absolute screen coordinates
this.indicatorPosition = {
top: screenTop,
left: screenLeft + 10, // Slight offset
};
// Check window boundaries to ensure the indicator stays visible
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Keep indicator within viewport bounds
if (this.indicatorPosition.left + 150 > viewportWidth) {
this.indicatorPosition.left = viewportWidth - 160;
}
if (this.indicatorPosition.top + 40 > viewportHeight) {
this.indicatorPosition.top = viewportHeight - 50;
}
// Show the indicator
this.showCommentIndicator = true;
// Request an update to ensure UI reflects changes
this.requestUpdate();
}
} catch (error) {
console.error("Error positioning comment indicator:", error);
}
}
} catch (error) {
console.error("Error handling selection change:", error);
}
}
/**
* Handle click on the comment indicator
*/
private handleIndicatorClick(e: Event) {
try {
e.stopPropagation();
e.preventDefault();
this.showCommentBox = true;
this.commentText = ""; // Reset comment text
// Don't hide the indicator while comment box is shown
this.showCommentIndicator = true;
// Ensure UI updates
this.requestUpdate();
} catch (error) {
console.error("Error handling indicator click:", error);
}
}
/**
* Handle changes to the comment text
*/
private handleCommentInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
this.commentText = target.value;
}
/**
* Close the comment box
*/
private closeCommentBox() {
this.showCommentBox = false;
// Also hide the indicator
this.showCommentIndicator = false;
}
/**
* Submit the comment
*/
private submitComment() {
try {
if (!this.selectedText || !this.commentText) {
console.log("Missing selected text or comment");
return;
}
// Get the correct filename based on active editor
const fileContext =
this.activeEditor === "original"
? this.originalFilename || "Original file"
: this.modifiedFilename || "Modified file";
// Include editor info to make it clear which version was commented on
const editorLabel =
this.activeEditor === "original" ? "[Original]" : "[Modified]";
// Add line number information if available
let lineInfo = "";
if (this.selectionRange) {
const startLine = this.selectionRange.startLineNumber;
const endLine = this.selectionRange.endLineNumber;
if (startLine === endLine) {
lineInfo = ` (line ${startLine})`;
} else {
lineInfo = ` (lines ${startLine}-${endLine})`;
}
}
// Format the comment in a readable way
const formattedComment = `\`\`\`\n${fileContext} ${editorLabel}${lineInfo}:\n${this.selectedText}\n\`\`\`\n\n${this.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: this.selectedText,
commentText: this.commentText,
formattedComment,
selectionRange: this.selectionRange,
activeEditor: this.activeEditor,
},
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();
}
}
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 = monaco.Uri.parse(
`file:///original-${timestamp}.${originalLang}`,
);
const modifiedUri = 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 = monaco.editor.createModel(
this.originalCode || "",
originalLang,
originalUri,
);
this.modifiedModel = 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,
});
}
this.setupContentChangeListener();
} catch (error) {
console.error("Error updating Monaco models:", error);
}
}
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 layout recalculation after model updates
setTimeout(() => {
if (this.editor) {
this.editor.layout();
}
}, 50);
} else {
// If the editor isn't initialized yet but we received content,
// initialize it now
this.initializeEditor();
}
}
}
// Add resize observer to ensure editor resizes when container changes
firstUpdated() {
// Initialize the editor
this.initializeEditor();
// Create a ResizeObserver to monitor container size changes
if (window.ResizeObserver) {
const resizeObserver = new ResizeObserver(() => {
if (this.editor) {
this.editor.layout();
}
});
// Start observing the container
if (this.container.value) {
resizeObserver.observe(this.container.value);
}
// Store the observer for cleanup
this._resizeObserver = resizeObserver;
}
// 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 });
}
}
private _resizeObserver: ResizeObserver | null = null;
disconnectedCallback() {
super.disconnectedCallback();
try {
// 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 (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = null;
}
// Remove document click handler if set
if (this._documentClickHandler) {
document.removeEventListener("click", this._documentClickHandler);
this._documentClickHandler = null;
}
} catch (error) {
console.error("Error in disconnectedCallback:", error);
}
}
// disconnectedCallback implementation is defined below
}
declare global {
interface HTMLElementTagNameMap {
"sketch-monaco-view": CodeDiffEditor;
}
}