Skip to content

feature(gutter): add experimental ability to replace fold icon with custom for a specific row #5787

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/css/editor-css.js
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ module.exports = `
padding-right: 13px;
}

.ace_fold-widget {
.ace_fold-widget, .ace_custom-widget {
box-sizing: border-box;

margin: 0 -12px 0 1px;
Expand All @@ -525,6 +525,10 @@ module.exports = `
cursor: pointer;
}

.ace_custom-widget {
background: none;
}

.ace_folding-enabled .ace_fold-widget {
display: inline-block;
}
Expand Down
31 changes: 31 additions & 0 deletions src/edit_session.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
return this.join("\n");
};

// @experimental
this.$gutterCustomWidgets = {};

// Set default background tokenizer with Text mode until editor session mode is set
this.bgTokenizer = new BackgroundTokenizer((new TextMode()).getTokenizer(), this);

Expand Down Expand Up @@ -585,6 +588,34 @@
this._signal("changeBreakpoint", {});
}

/**
* Replaces the custom icon with the fold widget if present from a specific row in the gutter
* @param {number} row The row number for which to hide the custom icon
* @experimental
*/
removeGutterCustomWidget(row) {
if(this.$editor) {
this.$editor.renderer.$gutterLayer.$removeCustomWidget(row);

Check warning on line 598 in src/edit_session.js

View check run for this annotation

Codecov / codecov/patch

src/edit_session.js#L596-L598

Added lines #L596 - L598 were not covered by tests
}
}

/**
* Replaces the fold widget if present with the custom icon from a specific row in the gutter
* @param {number} row - The row number where the widget will be displayed
* @param {Object} attributes - Configuration attributes for the widget
* @param {string} attributes.className - CSS class name for styling the widget
* @param {string} attributes.label - Text label to display in the widget
* @param {string} attributes.title - Tooltip text for the widget
* @param {Object} attributes.callbacks - Event callback functions for the widget e.g onClick;
* @returns {void}
* @experimental
*/
addGutterCustomWidget(row,attributes) {
if(this.$editor) {
this.$editor.renderer.$gutterLayer.$addCustomWidget(row,attributes);

Check warning on line 615 in src/edit_session.js

View check run for this annotation

Codecov / codecov/patch

src/edit_session.js#L613-L615

Added lines #L613 - L615 were not covered by tests
}
}

/**
* Removes `className` from the `row`.
* @param {Number} row The row number
Expand Down
122 changes: 122 additions & 0 deletions src/layer/gutter.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@
var textNode = element.childNodes[0];
var foldWidget = element.childNodes[1];
var annotationNode = element.childNodes[2];
var customWidget = element.childNodes[3];
var annotationIconNode = annotationNode.firstChild;

var firstLineNumber = session.$firstLineNumber;
Expand Down Expand Up @@ -418,6 +419,7 @@
// Set a11y properties.
foldWidget.setAttribute("role", "button");
foldWidget.setAttribute("tabindex", "-1");

var foldRange = session.getFoldWidgetRange(row);

// getFoldWidgetRange is optional to be implemented by fold modes, if not available we fall-back.
Expand Down Expand Up @@ -460,6 +462,14 @@
foldWidget.removeAttribute("aria-label");
}
}
// fold logic ends here
const customWidgetAttributes = this.session.$gutterCustomWidgets[row];
if (customWidgetAttributes) {
this.$addCustomWidget(row, customWidgetAttributes,cell);

Check warning on line 468 in src/layer/gutter.js

View check run for this annotation

Codecov / codecov/patch

src/layer/gutter.js#L468

Added line #L468 was not covered by tests
}
else if (customWidget){
this.$removeCustomWidget(row,cell);

Check warning on line 471 in src/layer/gutter.js

View check run for this annotation

Codecov / codecov/patch

src/layer/gutter.js#L471

Added line #L471 was not covered by tests
}

if (annotationInFold && this.$showFoldedAnnotations){
annotationNode.className = "ace_gutter_annotation";
Expand Down Expand Up @@ -588,6 +598,118 @@
getShowFoldWidgets() {
return this.$showFoldWidgets;
}

/**
* Hides the fold widget/icon from a specific row in the gutter
* @param {number} row The row number from which to hide the fold icon
* @param {any} cell - Gutter cell
* @experimental
*/
$hideFoldWidget(row, cell) {
const rowCell = cell || this.$getGutterCell(row);
if (rowCell && rowCell.element) {
const foldWidget = rowCell.element.childNodes[1];
if (foldWidget) {
dom.setStyle(foldWidget.style, "display", "none");
}
}
}

/**
* Shows the fold widget/icon from a specific row in the gutter
* @param {number} row The row number from which to show the fold icon
* @param {any} cell - Gutter cell
* @experimental
*/
$showFoldWidget(row,cell) {
const rowCell = cell || this.$getGutterCell(row);
if (rowCell && rowCell.element) {
const foldWidget = rowCell.element.childNodes[1];
if (foldWidget && this.session.foldWidgets[rowCell.row]) {
dom.setStyle(foldWidget.style, "display", "inline-block");
}
}
}

/**
* Retrieves the gutter cell element at the specified cursor row position.
* @param {number} row - The row number in the editor where the gutter cell is located starts from 0
* @returns {HTMLElement|null} The gutter cell element at the specified row, or null if not found
* @experimental
*/
$getGutterCell(row) {
// contains only visible rows
const cells = this.$lines.cells;
const visibileRow= this.session.documentToScreenRow(row,0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentToScreenRow on large documents can be much slower than searching row in the cells array

Copy link
Contributor Author

@nlujjawal nlujjawal Apr 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Searching the cells doesn't work properly in case when the line is folded and you move the cursor to the right of the fold symbol in the code. It doesn't work because at that time cursor is on a hidden row.

I tried creating a large document of 50K lines and 100 custom widgets at a time, current code seems to be working fine without any lag.

// subtracting the first visible screen row index and folded rows from the row number.
return cells[row - this.config.firstRowScreen - (row-visibileRow)];
}

/**
* Displays a custom widget for a specific row
* @param {number} row - The row number where the widget will be displayed
* @param {Object} attributes - Configuration attributes for the widget
* @param {string} attributes.className - CSS class name for styling the widget
* @param {string} attributes.label - Text label to display in the widget
* @param {string} attributes.title - Tooltip text for the widget
* @param {Object} attributes.callbacks - Event callback functions for the widget e.g onClick;
* @param {any} cell - Gutter cell
* @returns {void}
* @experimental
*/
$addCustomWidget(row, {className, label, title, callbacks}, cell) {
this.session.$gutterCustomWidgets[row] = {className, label, title, callbacks};
this.$hideFoldWidget(row,cell);

// cell is required because when cached cell is used to render, $lines won't have that cell
const rowCell = cell || this.$getGutterCell(row);
if (rowCell && rowCell.element) {
let customWidget = rowCell.element.querySelector(".ace_custom-widget");
// deleting the old custom widget to remove the old click event listener
if (customWidget) {
customWidget.remove();
}

customWidget = dom.createElement("span");
customWidget.className = `ace_custom-widget ${className}`;
customWidget.setAttribute("tabindex", "-1");
customWidget.setAttribute("role", 'button');
customWidget.setAttribute("aria-label", label);
customWidget.setAttribute("title", title);
dom.setStyle(customWidget.style, "display", "inline-block");
dom.setStyle(customWidget.style, "height", "inherit");

if (callbacks&& callbacks.onClick) {
customWidget.addEventListener("click", (e) => {
callbacks.onClick(e, row);
e.stopPropagation();
});
}

rowCell.element.appendChild(customWidget);
}
}

/**
* Remove a custom widget for a specific row
* @param {number} row - The row number where the widget will be removed
* @param {any} cell - Gutter cell
* @returns {void}
* @experimental
*/
$removeCustomWidget(row, cell) {
delete this.session.$gutterCustomWidgets[row];
this.$showFoldWidget(row,cell);

// cell is required because when cached cell is used to render, $lines won't have that cell
const rowCell = cell || this.$getGutterCell(row);
if (rowCell && rowCell.element) {
const customWidget = rowCell.element.querySelector(".ace_custom-widget");
if (customWidget) {
rowCell.element.removeChild(customWidget);
}
}
}

$computePadding() {
if (!this.element.firstChild)
Expand Down
Loading