webui: improve comment functionality for Monaco diff editor by using gutter
This was a bit of a journey. To me, the correct functionality is like
the breakpoint setting in VSCode. I had o3 find the relevant code and
tried to feed it back into Sketch, but it turned out I had to break it
down into pieces. (Asking it for the whole thing failed several times.)
In the end, I asked Sketch first to add a comment icon in the gutters.
This required just the right "glyphMargin" settings and finally worked.
In the next session, it worked on showing/hiding them depending on where
you were hovering. In the one after that, it deleted the old comment box
functionality, and then in the one after that, added that functionality
back in. And now we're here, and it seems to work.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: sd81ff0507f3398a8k
diff --git a/webui/src/web-components/demo/comment-test.html b/webui/src/web-components/demo/comment-test.html
new file mode 100644
index 0000000..b4fd6f0
--- /dev/null
+++ b/webui/src/web-components/demo/comment-test.html
@@ -0,0 +1,143 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Monaco Comment Test</title>
+ <style>
+ body {
+ font-family:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
+ Arial, sans-serif;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 2rem;
+ }
+
+ h1 {
+ color: #333;
+ margin-bottom: 2rem;
+ }
+
+ .test-container {
+ border: 2px solid #ddd;
+ border-radius: 4px;
+ padding: 20px;
+ margin: 20px 0;
+ height: 400px;
+ }
+
+ .event-log {
+ background: #f5f5f5;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ padding: 10px;
+ margin: 20px 0;
+ max-height: 200px;
+ overflow-y: auto;
+ font-family: monospace;
+ font-size: 12px;
+ }
+
+ .event-log .event {
+ margin-bottom: 5px;
+ padding: 2px 0;
+ border-bottom: 1px solid #eee;
+ }
+
+ .instruction {
+ background: #e8f4f8;
+ border: 1px solid #bee5eb;
+ border-radius: 4px;
+ padding: 10px;
+ margin: 10px 0;
+ }
+ </style>
+ </head>
+ <body>
+ <h1>Monaco Comment Test</h1>
+
+ <div class="instruction">
+ <strong>Instructions:</strong>
+ <ol>
+ <li>Hover over a line to see the comment glyph (💬) appear</li>
+ <li>Click on the glyph to open a comment box for that line</li>
+ <li>
+ Try clicking and dragging from one glyph to another to select multiple
+ lines
+ </li>
+ <li>
+ <strong>NEW:</strong> Make a text selection in the editor, then click
+ a glyph - it will use your selection instead of the line
+ </li>
+ <li>Fill out the comment and click "Add" to test the event dispatch</li>
+ </ol>
+ </div>
+
+ <div class="test-container">
+ <sketch-monaco-view id="testEditor"></sketch-monaco-view>
+ </div>
+
+ <div class="event-log" id="eventLog">
+ <strong>Event Log:</strong>
+ </div>
+
+ <script type="module">
+ // Load the Monaco component
+ import "../../dist/web-components/sketch-monaco-view.js";
+
+ document.addEventListener("DOMContentLoaded", () => {
+ const editor = document.getElementById("testEditor");
+ const eventLog = document.getElementById("eventLog");
+
+ // Set up test content
+ editor.originalCode = `function calculateTotal(items) {
+ return items
+ .map(item => item.price * item.quantity)
+ .reduce((a, b) => a + b, 0);
+}`;
+
+ editor.modifiedCode = `function calculateTotal(items) {
+ // Apply discount if available
+ return items
+ .map(item => {
+ const price = item.discount ?
+ item.price * (1 - item.discount) :
+ item.price;
+ return price * item.quantity;
+ })
+ .reduce((a, b) => a + b, 0);
+}`;
+
+ editor.originalFilename = "original.js";
+ editor.modifiedFilename = "modified.js";
+
+ // Log events
+ function logEvent(message) {
+ const eventDiv = document.createElement("div");
+ eventDiv.className = "event";
+ eventDiv.textContent = `${new Date().toLocaleTimeString()}: ${message}`;
+ eventLog.appendChild(eventDiv);
+ eventLog.scrollTop = eventLog.scrollHeight;
+ }
+
+ // Listen for comment events
+ editor.addEventListener("monaco-comment", (event) => {
+ logEvent("Monaco comment event received");
+ logEvent(`File: ${event.detail.fileContext}`);
+ logEvent(`Editor: ${event.detail.activeEditor}`);
+ logEvent(
+ `Lines: ${event.detail.selectionRange.startLineNumber}-${event.detail.selectionRange.endLineNumber}`,
+ );
+ logEvent(
+ `Selected text: ${event.detail.selectedText.substring(0, 50)}...`,
+ );
+ logEvent(`Comment: ${event.detail.commentText}`);
+ console.log("Full comment event:", event.detail);
+ });
+
+ logEvent("Test page loaded. Monaco component initialized.");
+ });
+ </script>
+ </body>
+</html>
diff --git a/webui/src/web-components/sketch-monaco-view.ts b/webui/src/web-components/sketch-monaco-view.ts
index 4b46c0e..12dc00a 100644
--- a/webui/src/web-components/sketch-monaco-view.ts
+++ b/webui/src/web-components/sketch-monaco-view.ts
@@ -28,28 +28,42 @@
return Promise.resolve(window.monaco);
}
- monacoLoadPromise = new Promise((resolve, reject) => {
- // Get the Monaco hash from build-time constant
- const monacoHash = __MONACO_HASH__;
+ monacoLoadPromise = new Promise(async (resolve, reject) => {
+ try {
+ // Check if we're in development mode
+ const isDev = __MONACO_HASH__ === "dev";
- // 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);
+ if (isDev) {
+ // In development mode, import Monaco directly
+ const monaco = await import("monaco-editor");
+ window.monaco = monaco;
+ resolve(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"));
- };
+ // In production mode, load from external bundle
+ const monacoHash = __MONACO_HASH__;
- // Don't set type="module" since we're using IIFE format
- script.src = `./static/monaco-standalone-${monacoHash}.js`;
- document.head.appendChild(script);
+ // 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;
@@ -76,6 +90,29 @@
.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
@@ -120,35 +157,25 @@
@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;
+ // 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 showCommentIndicator: boolean = false;
-
- @state()
- private indicatorPosition: { top: number; left: number } = {
+ @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;
- @state()
- private showCommentBox: boolean = false;
-
- @state()
- private commentText: string = "";
-
- @state()
- private activeEditor: "original" | "modified" = "modified"; // Track which editor is active
+ // Track visible glyphs to ensure proper cleanup
+ private visibleGlyphs: Set<string> = new Set();
// Custom event to request save action from external components
private requestSave() {
@@ -233,6 +260,17 @@
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);
});
}
@@ -292,32 +330,7 @@
box-sizing: border-box; /* Include border in width calculation */
}
- /* 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 styles */
.comment-box {
position: fixed;
background-color: white;
@@ -366,7 +379,7 @@
margin-bottom: 10px;
font-family: monospace;
font-size: 12px;
- max-height: 80px;
+ max-height: 100px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
@@ -450,39 +463,13 @@
`
: ""}
- <!-- 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 -->
+ <!-- Comment box - shown when glyph is clicked -->
${this.showCommentBox
? html`
<div
class="comment-box"
- style="${this.calculateCommentBoxPosition()}"
- @mouseenter="${() => {
- this._isHovering = true;
- }}"
- @mouseleave="${() => {
- this._isHovering = false;
- }}"
+ style="top: ${this.commentBoxPosition.top}px; left: ${this
+ .commentBoxPosition.left}px;"
>
<div class="comment-box-header">
<h3>Add comment</h3>
@@ -490,7 +477,13 @@
×
</button>
</div>
- <div class="selected-text-preview">${this.selectedText}</div>
+ ${this.selectedLines
+ ? html`
+ <div class="selected-text-preview">
+ ${this.selectedLines.text}
+ </div>
+ `
+ : ""}
<textarea
class="comment-textarea"
placeholder="Type your comment here..."
@@ -512,44 +505,161 @@
}
/**
+ * 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;
+ 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(): 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;
+ 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
+ const boxWidth = 350;
+ const boxHeight = 300;
+
+ // 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);
}
- // Ensure box is never positioned off-screen
- top = Math.max(10, top);
- left = Math.max(10, left);
-
- return `top: ${top}px; left: ${left}px;`;
+ return { top: 100, left: 100 };
}
setOriginalCode(code: string, filename?: string) {
@@ -619,6 +729,417 @@
}
/**
+ * 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) {
@@ -655,6 +1176,10 @@
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
@@ -686,6 +1211,8 @@
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
@@ -704,17 +1231,22 @@
},
});
- // 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 });
+ this.editor
+ .getOriginalEditor()
+ .updateOptions({ readOnly: true, glyphMargin: true });
// Make sure the modified editor is editable
- this.editor.getModifiedEditor().updateOptions({ readOnly: false });
+ 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
@@ -726,6 +1258,8 @@
// Create or update models
this.updateModels();
+ // Add glyph decorations after models are set
+ this.setupGlyphDecorations();
// Set up content change listener
this.setupContentChangeListener();
@@ -748,336 +1282,6 @@
}
}
- /**
- * 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) {
- return;
- }
-
- // Get both original and modified editors
- const originalEditor = this.editor.getOriginalEditor();
- const modifiedEditor = this.editor.getModifiedEditor();
-
- if (!originalEditor || !modifiedEditor) {
- 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);
- } 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) {
- return;
- }
-
- // Make sure selection is within valid range
- const lineCount = model.getLineCount();
- if (
- e.selection.startLineNumber > lineCount ||
- e.selection.endLineNumber > lineCount
- ) {
- 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) {
- 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) {
- 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
@@ -1150,6 +1354,9 @@
if (this.fitEditorToContent) {
setTimeout(() => this.fitEditorToContent!(), 50);
}
+
+ // Add glyph decorations after setting new models
+ setTimeout(() => this.setupGlyphDecorations(), 100);
}
this.setupContentChangeListener();
} catch (error) {
@@ -1390,6 +1597,17 @@
}
}
+ // 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();
@@ -1416,17 +1634,14 @@
// Clear the fit function reference
this.fitEditorToContent = null;
- // Remove document click handler if set
- if (this._documentClickHandler) {
- document.removeEventListener("click", this._documentClickHandler);
- this._documentClickHandler = 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);
}